diff --git a/lib/egghead.ex b/lib/egghead.ex
index d2b267f..b67a065 100644
--- a/lib/egghead.ex
+++ b/lib/egghead.ex
@@ -22,6 +22,9 @@ defmodule Egghead do
* `:class` — `:durable`, `:inbox`, `:deliberation`, `:transcript`,
`:agent`, or `:skill` (default: `:durable`)
* `:body` — the record body text
+ * `:format` — `:markdown` (default) or `:org`. Determines the on-disk
+ format and the file extension (`.md` or `.org`). The system default
+ can be overridden via `default_format:` in `config.yml`.
"""
@spec create_record(map()) :: {:ok, Record.t()} | {:error, term()}
defdelegate create_record(attrs), to: RecordStore
diff --git a/lib/egghead/application.ex b/lib/egghead/application.ex
index 50eb146..518927c 100644
--- a/lib/egghead/application.ex
+++ b/lib/egghead/application.ex
@@ -196,6 +196,7 @@ defmodule Egghead.Application do
Application.put_env(:egghead, :records_dir, Egghead.Config.records_dir(config))
Application.put_env(:egghead, :skills_dir, Egghead.Config.skills_dir(config))
Application.put_env(:egghead, :mcp_servers, config.mcp_servers)
+ Application.put_env(:egghead, :default_format, config.default_format || :markdown)
if config.server do
Application.put_env(:egghead, :server, config.server)
diff --git a/lib/egghead/capability.ex b/lib/egghead/capability.ex
index 28ded05..e75cacf 100644
--- a/lib/egghead/capability.ex
+++ b/lib/egghead/capability.ex
@@ -37,9 +37,13 @@ defmodule Egghead.Capability do
Parses a frontmatter `capabilities:` value into a list of grants.
Accepts:
- - A list of strings: `["records.read", "agent.create"]`
- - A list mixing strings and maps (scoped): `["records.read", %{"net.get" => %{"hosts" => [...]}}]`
- - A comma/space-separated string: `"records.read agent.create"`
+ - A list of strings: `["records.read", "agent.create"]`.
+ - A list mixing strings and maps (scoped): `["records.read", %{"net.get" => %{"hosts" => [...]}}]`.
+ - A comma/space-separated string: `"records.read agent.create"`.
+ - Tokens in the same compact spec form `egghead agent grant` accepts:
+ `"net.get{hosts=[*.github.com]} records.read"`. Useful in contexts
+ where the YAML nested-map form isn't available — org property
+ drawers, CLI input, environment variables.
Unknown strings are logged and dropped (forward-compat for skills
referencing future capability names).
@@ -55,7 +59,7 @@ defmodule Egghead.Capability do
def parse(str) when is_binary(str) do
str
- |> String.split(~r/[,\s]+/, trim: true)
+ |> tokenize()
|> parse()
end
@@ -64,10 +68,54 @@ defmodule Egghead.Capability do
[]
end
+ @doc """
+ Split a capabilities string into tokens, respecting `{...}` scope
+ bodies. Whitespace and commas separate top-level tokens; commas inside
+ a scope body are part of its value list, not a token boundary.
+
+ Public so other layers (the agent record projection) can pre-tokenize
+ consistently when their input is also a flat string.
+ """
+ @spec tokenize(String.t()) :: [String.t()]
+ def tokenize(str) do
+ {tokens, current, depth} =
+ str
+ |> String.graphemes()
+ |> Enum.reduce({[], "", 0}, fn
+ c, {acc, cur, d} when c in ["{", "["] ->
+ {acc, cur <> c, d + 1}
+
+ c, {acc, cur, d} when c in ["}", "]"] ->
+ {acc, cur <> c, max(d - 1, 0)}
+
+ ch, {acc, cur, 0} when ch in [" ", "\t", "\n", ","] ->
+ if cur == "", do: {acc, "", 0}, else: {[cur | acc], "", 0}
+
+ ch, {acc, cur, d} ->
+ {acc, cur <> ch, d}
+ end)
+
+ tokens = if current == "", do: tokens, else: [current | tokens]
+
+ if depth > 0,
+ do: Logger.warning("Capability.parse: unbalanced brackets in #{inspect(str)}")
+
+ Enum.reverse(tokens)
+ end
+
defp parse_one(str) when is_binary(str) do
- case split_resource_verb(str) do
- {:ok, resource, verb} -> [%Grant{resource: resource, verb: verb, scope: %{}}]
- :error -> warn_drop(str)
+ case parse_grant_spec(str) do
+ {:ok, bare} when is_binary(bare) ->
+ case split_resource_verb(bare) do
+ {:ok, resource, verb} -> [%Grant{resource: resource, verb: verb, scope: %{}}]
+ :error -> warn_drop(str)
+ end
+
+ {:ok, %{} = scoped_map} ->
+ parse_one(scoped_map)
+
+ {:error, _} ->
+ warn_drop(str)
end
end
diff --git a/lib/egghead/config.ex b/lib/egghead/config.ex
index 06e40b0..6217820 100644
--- a/lib/egghead/config.ex
+++ b/lib/egghead/config.ex
@@ -36,6 +36,7 @@ defmodule Egghead.Config do
llm: [],
default_model: nil,
default_room: nil,
+ default_format: :markdown,
theme: "terminal-dark",
web: %{port: 4000, host: "localhost", bind: "127.0.0.1"},
mcp_servers: [],
@@ -65,6 +66,7 @@ defmodule Egghead.Config do
llm: [llm_entry()],
default_model: String.t() | nil,
default_room: String.t() | nil,
+ default_format: :markdown | :org,
theme: String.t(),
web: %{port: non_neg_integer(), host: String.t(), bind: String.t()},
mcp_servers: [mcp_server()]
@@ -274,6 +276,7 @@ defmodule Egghead.Config do
llm: parse_llm(data["llm"]),
default_model: data["default_model"],
default_room: data["default_room"],
+ default_format: parse_format(data["default_format"]),
theme: data["theme"] || "terminal-dark",
web: parse_web(data["web"]),
mcp_servers: parse_mcp_servers(data["mcp_servers"]),
@@ -321,6 +324,14 @@ defmodule Egghead.Config do
defp parse_mcp_server(_), do: :error
+ # Default record format. `org` enables full org-mode treatment for newly
+ # created records; existing files keep their on-disk format regardless.
+ defp parse_format("org"), do: :org
+ defp parse_format(:org), do: :org
+ defp parse_format("markdown"), do: :markdown
+ defp parse_format(:markdown), do: :markdown
+ defp parse_format(_), do: :markdown
+
defp parse_transport("stdio"), do: :stdio
defp parse_transport("http"), do: :http
defp parse_transport(:stdio), do: :stdio
@@ -414,6 +425,7 @@ defmodule Egghead.Config do
emit_llm(config.llm),
emit_field("default_model", config.default_model),
emit_field("default_room", config.default_room),
+ emit_format(config.default_format),
emit_field("theme", config.theme),
emit_web(config.web),
emit_mcp_servers(config.mcp_servers)
@@ -493,6 +505,11 @@ defmodule Egghead.Config do
defp emit_field(_key, nil), do: nil
defp emit_field(key, value), do: "#{key}: #{yaml_escape(value)}"
+ # Markdown is the implicit default; only emit when the user has opted into
+ # org. Keeps untouched config files clean.
+ defp emit_format(:org), do: "default_format: org"
+ defp emit_format(_), do: nil
+
defp emit_llm([]), do: nil
defp emit_llm(entries) do
diff --git a/lib/egghead/mcp/handler.ex b/lib/egghead/mcp/handler.ex
index 631ef3d..65464c0 100644
--- a/lib/egghead/mcp/handler.ex
+++ b/lib/egghead/mcp/handler.ex
@@ -125,7 +125,7 @@ defmodule Egghead.MCP.Handler do
%{
name: "egghead_create",
description:
- "Create a new record. Writes a Markdown file to the records directory. Returns the created record.",
+ "Create a new record. Writes a Markdown (.md) or org-mode (.org) file to the records directory based on the `format` field (default: markdown, or whatever `default_format` is set to in config.yml). Returns the created record.",
inputSchema: %{
type: "object",
properties: %{
@@ -146,7 +146,17 @@ defmodule Egghead.MCP.Handler do
enum: ["durable", "inbox", "deliberation"],
description: "Record class (default: durable)"
},
- body: %{type: "string", description: "Record body content (Markdown)"}
+ format: %{
+ type: "string",
+ enum: ["markdown", "org"],
+ description:
+ "On-disk format. `markdown` writes a `.md` file with YAML frontmatter; `org` writes a `.org` file with `#+`-keywords and a properties drawer. Defaults to the configured `default_format` (markdown if unset)."
+ },
+ body: %{
+ type: "string",
+ description:
+ "Record body content. Use Markdown for `.md` records or org syntax for `.org` records. For `.org`, the body is the full file content — `#+`-keywords and property drawers are part of the document."
+ }
},
required: ["title"]
}
@@ -154,7 +164,7 @@ defmodule Egghead.MCP.Handler do
%{
name: "egghead_update",
description:
- "Update an existing record. Merges the given fields into the record. Omitted fields are left unchanged. To replace the body entirely, pass the full new body.",
+ "Update an existing record. Merges the given fields into the record. Omitted fields are left unchanged. The on-disk format is preserved — for `.org` files the change is line-spliced (the rest of the file stays byte-identical), so it's safe to update one field at a time without re-writing the whole document. To replace the body entirely, pass the full new body.",
inputSchema: %{
type: "object",
properties: %{
@@ -170,7 +180,11 @@ defmodule Egghead.MCP.Handler do
items: %{type: "string"},
description: "New linked record ids (replaces existing links)"
},
- body: %{type: "string", description: "New body content (Markdown)"}
+ body: %{
+ type: "string",
+ description:
+ "New body content. For org records, this is the full file content — write back exactly what you read."
+ }
},
required: ["id"]
}
@@ -321,7 +335,7 @@ defmodule Egghead.MCP.Handler do
defp call_tool("egghead_create", args) do
attrs =
args
- |> Map.take(["id", "title", "tags", "links", "class", "body"])
+ |> Map.take(["id", "title", "tags", "links", "class", "body", "format"])
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
@@ -530,6 +544,7 @@ defmodule Egghead.MCP.Handler do
if(record.created, do: "created: #{record.created}"),
if(record.updated, do: "updated: #{record.updated}"),
"class: #{record.class}",
+ "format: #{record.format}",
if(record.tags != [], do: "tags: #{Enum.join(record.tags, ", ")}"),
references_line(record),
if(record.meta != %{}, do: "meta: #{Jason.encode!(record.meta)}")
diff --git a/lib/egghead/open_tui/markdown.ex b/lib/egghead/open_tui/markdown.ex
index 1430885..d5cdfea 100644
--- a/lib/egghead/open_tui/markdown.ex
+++ b/lib/egghead/open_tui/markdown.ex
@@ -606,16 +606,22 @@ defmodule Egghead.OpenTUI.Markdown do
# ---- span-aware word wrap ----------------------------------------------
- # Wrap a list of spans into rows of <= width columns. Hard
- # newlines (from
and embedded "\n") force a row break;
- # everything else is greedy-fill at word boundaries.
- defp wrap_spans(spans, width) when width > 0 do
+ @doc """
+ Wrap a list of spans into rows of `<= width` columns. Hard newlines
+ (from `
` or embedded `"\n"`) force a row break; everything else is
+ greedy-fill at word boundaries.
+
+ Public so any other renderer producing the same span shape can reuse
+ the same wrap behavior.
+ """
+ @spec wrap_spans([span()], pos_integer()) :: rendered()
+ def wrap_spans(spans, width) when width > 0 do
spans
|> tokenize()
|> fill_rows(width)
end
- defp wrap_spans(_spans, _width), do: [[]]
+ def wrap_spans(_spans, _width), do: [[]]
# Convert spans to a flat token stream of:
# {:word, span} — non-whitespace run, atomic
diff --git a/lib/egghead/record/agent.ex b/lib/egghead/record/agent.ex
index 69516c9..01c19dd 100644
--- a/lib/egghead/record/agent.ex
+++ b/lib/egghead/record/agent.ex
@@ -23,6 +23,7 @@ defmodule Egghead.Record.Agent do
alias Egghead.Capability
alias Egghead.LLM.Registry
alias Egghead.Record
+ alias Egghead.Record.OrgParser
@default_context_threshold 0.70
@default_max_tokens 4096
@@ -73,7 +74,7 @@ defmodule Egghead.Record.Agent do
%__MODULE__{
id: record.id,
name: record.title || record.id,
- disposition: record.body || "",
+ disposition: disposition_from(record),
model: resolve_model(record),
provider: meta_string(record, "provider"),
capabilities: parse_capabilities(record),
@@ -233,11 +234,25 @@ defmodule Egghead.Record.Agent do
defp normalize_caps(nil), do: []
defp normalize_caps(list) when is_list(list), do: list
- defp normalize_caps(str) when is_binary(str), do: String.split(str, ~r/[,\s]+/, trim: true)
+ # Use the paren-aware tokenizer so textual scope syntax —
+ # `net.get(hosts=a,b)` — survives intact through to `Capability.parse/1`.
+ defp normalize_caps(str) when is_binary(str), do: Capability.tokenize(str)
defp normalize_caps(_), do: []
# --- Helpers ---
+ # The disposition is the prose body of the agent record — what gets
+ # injected into the model's system prompt. For markdown agents `body`
+ # is already post-frontmatter so we use it directly. For org agents
+ # `body` is the full file content (the org rule: file == document),
+ # so we strip the file-level keyword block and properties drawer for
+ # the prompt projection. The on-disk file is unchanged; this is just
+ # what the model sees.
+ defp disposition_from(%Record{format: :org, body: body}) when is_binary(body),
+ do: OrgParser.body_without_preamble(body)
+
+ defp disposition_from(%Record{body: body}), do: body || ""
+
defp resolve_model(%Record{meta: meta}) do
raw = meta_string_value(meta["model"])
provider = meta_string_value(meta["provider"])
diff --git a/lib/egghead/record/org_parser.ex b/lib/egghead/record/org_parser.ex
index c6139b6..b51c807 100644
--- a/lib/egghead/record/org_parser.ex
+++ b/lib/egghead/record/org_parser.ex
@@ -202,6 +202,66 @@ defmodule Egghead.Record.OrgParser do
collect_links(ast, []) |> Enum.reverse()
end
+ @doc """
+ Returns the body of an org file with the file-level preamble stripped:
+ the contiguous block of `#+`-keywords and `#`-comments at the top, and
+ the first `:PROPERTIES:` drawer if it appears before any headline.
+
+ Used to project an org agent record's body into a system prompt: the
+ on-disk file keeps every `#+TITLE:` and drawer entry (those are the
+ document), but the prompt the model sees is the prose part starting
+ from the first real content.
+
+ This is a *projection*; the source file is unchanged.
+ """
+ @spec body_without_preamble(String.t()) :: String.t()
+ def body_without_preamble(content) when is_binary(content) do
+ lines = String.split(content, "\n")
+ {_skipped, rest} = drop_keyword_prefix(lines)
+ {_drawer, rest} = drop_leading_drawer(rest)
+
+ rest
+ |> Enum.drop_while(&blank?/1)
+ |> Enum.join("\n")
+ |> String.trim_trailing()
+ end
+
+ defp drop_keyword_prefix(lines) do
+ Enum.split_while(lines, fn line ->
+ Regex.match?(~r/^[ \t]*#\+\w+:/, line) or
+ Regex.match?(~r/^[ \t]*#[^+]/, line) or
+ blank?(line)
+ end)
+ end
+
+ # If the next non-blank line opens a properties drawer (and no headline
+ # has appeared), consume up to the matching :END:. Headline-attached
+ # drawers belong to that subtree and are left in place.
+ defp drop_leading_drawer(lines) do
+ {leading_blanks, rest} = Enum.split_while(lines, &blank?/1)
+
+ case rest do
+ [first | tail] ->
+ if Regex.match?(~r/^[ \t]*:PROPERTIES:[ \t]*$/, first) do
+ case Enum.split_while(tail, fn l -> not Regex.match?(~r/^[ \t]*:END:[ \t]*$/, l) end) do
+ {drawer_lines, [end_line | after_end]} ->
+ {[first | drawer_lines] ++ [end_line], after_end}
+
+ {_drawer_lines, []} ->
+ # Unterminated drawer — leave content alone.
+ {[], leading_blanks ++ rest}
+ end
+ else
+ {[], leading_blanks ++ rest}
+ end
+
+ [] ->
+ {[], leading_blanks}
+ end
+ end
+
+ defp blank?(line), do: Regex.match?(~r/^\s*$/, line)
+
@doc """
Extracts all source blocks from the AST.
"""
@@ -264,7 +324,12 @@ defmodule Egghead.Record.OrgParser do
true ->
{para_lines, rest} = collect_paragraph([line | rest], [])
- inline = parse_inline_text(Enum.join(para_lines, "\n"))
+ # Join with a single space so the inline parser (whose tokens
+ # exclude `\n`) sees one logical line. Org treats line breaks
+ # within a paragraph as soft — they render as spaces, like
+ # markdown — so collapsing is correct, and it lets bold/italic/
+ # link parsing work across visually-wrapped source lines.
+ inline = parse_inline_text(Enum.map_join(para_lines, " ", &String.trim_trailing/1))
node = {:paragraph, %{}, inline}
parse_lines(rest, [node | acc], state)
end
diff --git a/lib/egghead/record/org_writer.ex b/lib/egghead/record/org_writer.ex
new file mode 100644
index 0000000..34a8ab2
--- /dev/null
+++ b/lib/egghead/record/org_writer.ex
@@ -0,0 +1,344 @@
+defmodule Egghead.Record.OrgWriter do
+ @moduledoc """
+ Writer for org-mode records. Two distinct paths:
+
+ - `new/1` — render a brand-new record from attrs as a minimal org skeleton
+ (`#+TITLE:` block + body).
+ - `splice_metadata/2` — apply caller attrs to an *existing* on-disk org file
+ by editing only the affected `#+`-keyword lines and properties drawer
+ entries. The rest of the file is left byte-identical: blank lines, casing
+ of the user's `#+title:` vs `#+TITLE:`, ordering, comments, headlines —
+ all preserved.
+
+ This is the writer counterpart to `Egghead.Record.Parser.parse_org/2`.
+ Together they enforce the rule that for org files, **the file on disk is
+ the document**. Egghead never re-renders an org file from scratch on save.
+
+ ## Why targeted splicing instead of a re-render
+
+ YAML frontmatter is structural; we can pull it out, edit, and emit fresh
+ YAML on every save without bothering the user. Org `#+`-keywords and
+ property drawers are *part of the document*. A re-render would normalize
+ whitespace, change keyword casing, reorder keys, and drop unknown fields
+ the parser didn't promote to metadata. That breaks CRDT byte-identity and
+ surprises org users who expect their files to stay theirs.
+ """
+
+ @doc """
+ Renders a new org file from attrs. Used when creating a record from
+ scratch (no existing file to preserve).
+
+ Generates a minimal preamble — `#+TITLE`, `#+AUTHOR`, `#+FILETAGS`, plus
+ a small `:PROPERTIES:` drawer for `id`/`links`/`class` and any extra
+ meta — followed by the body. Written exactly once; subsequent updates
+ use `splice_metadata/2` to mutate in place.
+ """
+ @spec new(map()) :: String.t()
+ def new(attrs) do
+ id = attrs["id"]
+ title = attrs["title"]
+ author = attrs["author"]
+ tags = attrs["tags"] || []
+ links = attrs["links"] || []
+ class = attrs["class"] || "durable"
+ body = attrs["body"] || ""
+
+ extra_meta =
+ attrs
+ |> Map.drop(~w(id title author tags links class body created updated))
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+ |> Enum.sort_by(fn {k, _} -> k end)
+
+ keyword_lines =
+ [
+ if(title, do: "#+TITLE: #{title}"),
+ if(author, do: "#+AUTHOR: #{author}"),
+ if(tags != [], do: "#+FILETAGS: #{format_filetags(tags)}")
+ ]
+ |> Enum.reject(&is_nil/1)
+
+ drawer_lines =
+ [
+ ":PROPERTIES:",
+ if(id, do: ":ID: #{id}"),
+ if(links != [], do: ":LINKS: #{Enum.join(links, " ")}"),
+ ":CLASS: #{class}"
+ ] ++
+ Enum.map(extra_meta, fn {k, v} -> ":#{drawer_key(k)}: #{format_drawer_value(v)}" end) ++
+ [":END:"]
+
+ drawer_lines = Enum.reject(drawer_lines, &is_nil/1)
+
+ preamble = Enum.join(keyword_lines ++ drawer_lines, "\n")
+
+ body_part =
+ cond do
+ body == "" -> ""
+ true -> "\n\n" <> String.trim_leading(body)
+ end
+
+ String.trim_trailing(preamble <> body_part) <> "\n"
+ end
+
+ @doc """
+ Applies `attrs` to existing org `content` by mutating only the affected
+ lines. Returns the updated content.
+
+ Behavior per attribute:
+
+ - `title`/`author`: replaces the existing `#+TITLE:`/`#+AUTHOR:` line
+ in-place (preserves the user's casing of the keyword), or inserts a
+ new one near the top if absent.
+ - `tags`: replaces the existing `#+FILETAGS:` line (or inserts one);
+ `#+TAGS:` is also recognised on read but write canonically uses
+ `#+FILETAGS`.
+ - `links`/`class`/`id` and arbitrary extras: written into the file-level
+ `:PROPERTIES:` drawer (one before the first headline). The drawer
+ is created if missing.
+ - `body`: spliced via `Egghead.RecordStore` — `splice_metadata/2`
+ operates on the metadata layer only.
+
+ The sentinel `:remove` deletes that key — the corresponding keyword
+ line or drawer entry is dropped. Nil values are no-ops (preserves
+ existing).
+ """
+ @spec splice_metadata(String.t(), map()) :: String.t()
+ def splice_metadata(content, attrs) when is_map(attrs) do
+ content
+ |> splice_keyword("TITLE", attrs["title"])
+ |> splice_keyword("AUTHOR", attrs["author"])
+ |> splice_filetags(attrs["tags"])
+ |> splice_drawer_entries(attrs)
+ end
+
+ # --- Keyword line splicing ---
+
+ # Find the first matching `#+KEY:` line (case-insensitive) and replace
+ # its value, preserving the user's casing of the keyword. If no line
+ # exists, insert a new one before any other content (so the keyword
+ # block stays at the top).
+ defp splice_keyword(content, _key, nil), do: content
+
+ defp splice_keyword(content, key, :remove) do
+ pattern = ~r/^([ \t]*)#\+#{key}:[ \t]*[^\n]*\n?/im
+ Regex.replace(pattern, content, "", global: false)
+ end
+
+ defp splice_keyword(content, key, value) when is_binary(value) do
+ pattern = ~r/^([ \t]*)(#\+)(#{key})(:[ \t]*)([^\n]*)/im
+
+ if Regex.match?(pattern, content) do
+ Regex.replace(pattern, content, "\\1\\2\\3\\4#{escape_replacement(value)}", global: false)
+ else
+ insert_keyword_at_top(content, "#+#{key}: #{value}")
+ end
+ end
+
+ defp splice_keyword(content, key, value), do: splice_keyword(content, key, to_string(value))
+
+ defp splice_filetags(content, nil), do: content
+ defp splice_filetags(content, []), do: splice_keyword(content, "FILETAGS", :remove)
+
+ defp splice_filetags(content, tags) when is_list(tags) do
+ formatted = format_filetags(tags)
+ # Prefer existing #+FILETAGS but fall back to #+TAGS if that's what the
+ # user used.
+ cond do
+ Regex.match?(~r/^[ \t]*#\+FILETAGS:/im, content) ->
+ splice_keyword(content, "FILETAGS", formatted)
+
+ Regex.match?(~r/^[ \t]*#\+TAGS:/im, content) ->
+ splice_keyword(content, "TAGS", formatted)
+
+ true ->
+ insert_keyword_at_top(content, "#+FILETAGS: #{formatted}")
+ end
+ end
+
+ # Insert a new keyword line at the top, after any existing keyword/comment
+ # block but before any other content (drawers, headlines, paragraphs).
+ defp insert_keyword_at_top(content, line) do
+ lines = String.split(content, "\n")
+ {prefix, rest} = take_keyword_prefix(lines, [])
+ # Append the new line to the end of the existing keyword run so multiple
+ # inserted-from-empty keywords stay together at the top in insertion order.
+ Enum.join(prefix ++ [line | rest], "\n")
+ end
+
+ defp take_keyword_prefix([], acc), do: {Enum.reverse(acc), []}
+
+ defp take_keyword_prefix([line | rest], acc) do
+ cond do
+ Regex.match?(~r/^[ \t]*#\+\w+:/, line) -> take_keyword_prefix(rest, [line | acc])
+ Regex.match?(~r/^[ \t]*#[^+]/, line) -> take_keyword_prefix(rest, [line | acc])
+ true -> {Enum.reverse(acc), [line | rest]}
+ end
+ end
+
+ # --- Properties drawer splicing ---
+
+ defp splice_drawer_entries(content, attrs) do
+ extras =
+ attrs
+ |> Map.drop(~w(id title author tags links class body created updated))
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+
+ drawer_updates =
+ [
+ {"ID", attrs["id"]},
+ {"LINKS", attrs["links"]},
+ {"CLASS", attrs["class"]}
+ ] ++ Enum.map(extras, fn {k, v} -> {drawer_key(k), v} end)
+
+ drawer_updates =
+ Enum.reject(drawer_updates, fn
+ {_, nil} -> true
+ {k, _} when k not in ["ID", "LINKS", "CLASS"] -> false
+ _ -> false
+ end)
+
+ Enum.reduce(drawer_updates, content, fn {key, value}, acc ->
+ splice_drawer_entry(acc, key, value)
+ end)
+ end
+
+ defp splice_drawer_entry(content, _key, nil), do: content
+
+ defp splice_drawer_entry(content, key, :remove) do
+ case find_file_drawer(content) do
+ {:ok, before, drawer, after_drawer} ->
+ new_drawer = drop_drawer_line(drawer, key)
+
+ if drawer_empty?(new_drawer),
+ do: before <> after_drawer,
+ else: before <> new_drawer <> after_drawer
+
+ :none ->
+ content
+ end
+ end
+
+ defp splice_drawer_entry(content, key, value) do
+ formatted = format_drawer_value(value)
+
+ case find_file_drawer(content) do
+ {:ok, before, drawer, after_drawer} ->
+ new_drawer = upsert_drawer_line(drawer, key, formatted)
+ before <> new_drawer <> after_drawer
+
+ :none ->
+ new_drawer = ":PROPERTIES:\n:#{key}: #{formatted}\n:END:\n"
+ insert_drawer(content, new_drawer)
+ end
+ end
+
+ # Locate the file-level :PROPERTIES: drawer — the first one that appears
+ # before any headline. Returns `{:ok, before, drawer, after}` where
+ # concatenating the three pieces yields the original content.
+ defp find_file_drawer(content) do
+ pattern = ~r/(?:^|\n)([ \t]*:PROPERTIES:[ \t]*\n.*?:END:[ \t]*\n?)/s
+
+ case Regex.run(pattern, content, return: :index) do
+ [{full_start, full_len}, {drawer_start, drawer_len}] ->
+ # If a `*`-headline appears before this drawer, the drawer belongs
+ # to that subtree, not the file. Skip it.
+ before_text = binary_part(content, 0, drawer_start)
+
+ if Regex.match?(~r/(^|\n)\*+ /, before_text) do
+ :none
+ else
+ before = binary_part(content, 0, drawer_start)
+ drawer = binary_part(content, drawer_start, drawer_len)
+
+ after_drawer =
+ binary_part(
+ content,
+ full_start + full_len,
+ byte_size(content) - (full_start + full_len)
+ )
+
+ {:ok, before, drawer, after_drawer}
+ end
+
+ _ ->
+ :none
+ end
+ end
+
+ # Insert a new file-level drawer after the keyword/comment prefix and
+ # before any other content.
+ defp insert_drawer(content, drawer) do
+ lines = String.split(content, "\n")
+ {prefix, rest} = take_keyword_prefix(lines, [])
+
+ prefix_str =
+ case prefix do
+ [] -> ""
+ list -> Enum.join(list, "\n") <> "\n"
+ end
+
+ rest_str = Enum.join(rest, "\n")
+
+ cond do
+ prefix_str == "" -> drawer <> rest_str
+ true -> prefix_str <> drawer <> rest_str
+ end
+ end
+
+ defp upsert_drawer_line(drawer, key, value) do
+ pattern = ~r/^([ \t]*):#{key}:[ \t]*[^\n]*\n?/im
+
+ if Regex.match?(pattern, drawer) do
+ Regex.replace(pattern, drawer, "\\1:#{key}: #{escape_replacement(value)}\n", global: false)
+ else
+ # Insert before the :END: line.
+ Regex.replace(
+ ~r/([ \t]*):END:/,
+ drawer,
+ "\\1:#{key}: #{escape_replacement(value)}\n\\1:END:",
+ global: false
+ )
+ end
+ end
+
+ defp drop_drawer_line(drawer, key) do
+ Regex.replace(~r/^([ \t]*):#{key}:[ \t]*[^\n]*\n?/im, drawer, "", global: false)
+ end
+
+ # A drawer with no entries between :PROPERTIES: and :END: is empty.
+ defp drawer_empty?(drawer) do
+ inner = Regex.replace(~r/^[ \t]*:PROPERTIES:[ \t]*\n/, drawer, "", global: false)
+ inner = Regex.replace(~r/[ \t]*:END:[ \t]*\n?/, inner, "", global: false)
+ String.trim(inner) == ""
+ end
+
+ # --- Formatting helpers ---
+
+ @doc """
+ Formats a tag list as a `#+FILETAGS:` value.
+
+ Org's canonical FILETAGS form is `:foo:bar:baz:` (colon-delimited with
+ leading and trailing colons). Empty list returns `""`.
+ """
+ @spec format_filetags([String.t()]) :: String.t()
+ def format_filetags([]), do: ""
+
+ def format_filetags(tags) when is_list(tags) do
+ ":" <> Enum.join(tags, ":") <> ":"
+ end
+
+ defp format_drawer_value(value) when is_list(value), do: Enum.join(value, " ")
+ defp format_drawer_value(value) when is_binary(value), do: value
+ defp format_drawer_value(value), do: to_string(value)
+
+ defp drawer_key(key) when is_atom(key), do: key |> Atom.to_string() |> String.upcase()
+ defp drawer_key(key) when is_binary(key), do: String.upcase(key)
+
+ # Regex.replace interprets `\` and `\N` in the replacement; escape them so
+ # values containing backslashes are written literally.
+ defp escape_replacement(value) do
+ value
+ |> to_string()
+ |> String.replace("\\", "\\\\")
+ end
+end
diff --git a/lib/egghead/record/parser.ex b/lib/egghead/record/parser.ex
index 2a526da..1130b38 100644
--- a/lib/egghead/record/parser.ex
+++ b/lib/egghead/record/parser.ex
@@ -14,20 +14,56 @@ defmodule Egghead.Record.Parser do
@doc """
Detects the format of a record file from its content.
- Returns `:markdown` if the content starts with `---`,
- `:org` if it starts with `:PROPERTIES:`, or `:unknown` otherwise.
+ Returns `:markdown` if the content begins with YAML frontmatter (`---`),
+ `:org` if it begins with org-mode markers (a property drawer, a
+ `#+`-keyword, or a top-level `*`-headline), or `:unknown` otherwise.
+
+ Pass `source_path:` in `opts` to use the filename extension as a tiebreaker
+ for content that has no obvious format markers — `.org` wins over the
+ default markdown fallback.
"""
- @spec detect_format(String.t()) :: :markdown | :org | :unknown
- def detect_format(content) do
+ @spec detect_format(String.t(), keyword()) :: :markdown | :org | :unknown
+ def detect_format(content, opts \\ []) do
trimmed = String.trim_leading(content)
+ first_line = first_nonblank_line(trimmed)
cond do
- String.starts_with?(trimmed, "---") -> :markdown
- String.starts_with?(trimmed, ":PROPERTIES:") -> :org
- true -> :unknown
+ String.starts_with?(trimmed, "---") ->
+ :markdown
+
+ String.starts_with?(trimmed, ":PROPERTIES:") ->
+ :org
+
+ # File-level org keywords (`#+TITLE:`, `#+AUTHOR:`, etc.) are the
+ # most common org pattern and don't require a property drawer.
+ Regex.match?(~r/^#\+\w+:/, first_line) ->
+ :org
+
+ # Top-level org headlines (`* Heading`).
+ Regex.match?(~r/^\*+\s/, first_line) ->
+ :org
+
+ true ->
+ case Keyword.get(opts, :source_path) do
+ path when is_binary(path) ->
+ case Path.extname(path) do
+ ".org" -> :org
+ ".md" -> :markdown
+ _ -> :unknown
+ end
+
+ _ ->
+ :unknown
+ end
end
end
+ defp first_nonblank_line(content) do
+ content
+ |> String.split("\n")
+ |> Enum.find("", &(String.trim(&1) != ""))
+ end
+
@doc """
Parses file content into a `Record` struct. Detects format automatically.
@@ -38,7 +74,7 @@ defmodule Egghead.Record.Parser do
"""
@spec parse(String.t(), keyword()) :: {:ok, Record.t()} | {:error, term()}
def parse(content, opts \\ []) do
- case detect_format(content) do
+ case detect_format(content, opts) do
:markdown -> parse_markdown(content, opts)
:org -> parse_org(content, opts)
:unknown -> parse_markdown(content, opts)
@@ -73,19 +109,116 @@ defmodule Egghead.Record.Parser do
end
@doc """
- Parses an org-mode file with a property drawer into a `Record` struct.
+ Parses an org-mode file into a `Record` struct.
+
+ Org records have no separate "frontmatter" concept — `#+`-keywords and
+ property drawers are part of the document. This parser extracts metadata
+ by scanning the AST for `#+TITLE`/`#+AUTHOR`/`#+FILETAGS`/`#+CLASS` etc.
+ and any property drawer that appears before the first headline, but the
+ returned `record.body` is the **entire input content**, byte-faithful.
+
+ Renderers and editors operate on `record.body` directly so what's on disk
+ is what the user sees and edits.
"""
@spec parse_org(String.t(), keyword()) :: {:ok, Record.t()} | {:error, term()}
def parse_org(content, opts \\ []) do
- case split_property_drawer(content) do
- {:ok, props, body} ->
- org_title = extract_org_title(body)
- meta = normalize_org_props(props) |> Map.put("title", org_title)
- record = build_record(meta, extract_org_body(body), :org, opts)
- {:ok, record}
+ body = String.trim_trailing(content)
+ meta = extract_org_metadata(body)
+ record = build_record(meta, body, :org, opts)
+ {:ok, record}
+ end
- :error ->
- {:error, :no_property_drawer}
+ @doc """
+ Extracts file-level metadata from raw org content.
+
+ Walks the OrgParser AST and collects:
+
+ - All `#+KEY:` keyword values (downcased keys), with the last value winning
+ if a key appears multiple times.
+ - Properties from the first property drawer that appears before any headline
+ (treated as file-level properties; drawers attached to headlines stay
+ headline-local and are not extracted here).
+
+ Known keyword aliases are normalized: `#+FILETAGS` and `#+TAGS` both map to
+ `tags` (parsed as space- or colon-separated when given as a string), and
+ the property drawer's `:ID:` maps to `id`. Returns a flat string-keyed map
+ ready to feed `build_record/4`.
+ """
+ @spec extract_org_metadata(String.t()) :: map()
+ def extract_org_metadata(content) do
+ {:ok, ast} = Egghead.Record.OrgParser.parse(content)
+
+ keywords = collect_org_keywords(ast)
+ drawer_props = collect_first_file_drawer(ast)
+
+ keywords
+ |> Map.merge(drawer_props)
+ |> normalize_org_metadata()
+ end
+
+ defp collect_org_keywords(ast) do
+ Enum.reduce(ast, %{}, fn
+ {:keyword, %{key: key, value: value}, _}, acc ->
+ Map.put(acc, String.downcase(key), value)
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ # Only the FIRST property drawer encountered before any headline is treated
+ # as file-level. Drawers attached to headlines belong to that subtree and
+ # are not promoted to record metadata.
+ defp collect_first_file_drawer(ast) do
+ Enum.reduce_while(ast, %{}, fn
+ {:property_drawer, _, props}, _acc ->
+ map = Map.new(props, fn {k, v} -> {String.downcase(k), v} end)
+ {:halt, map}
+
+ {:headline, _, _}, _acc ->
+ {:halt, %{}}
+
+ _, acc ->
+ {:cont, acc}
+ end)
+ end
+
+ # Map org-flavored keys to record-frontmatter keys + parse list-y values.
+ # Org `#+FILETAGS: :foo:bar:` and `#+TAGS: foo bar` both feed `tags`;
+ # property drawers can use `:TAGS:` and `:LINKS:` with space-separated
+ # values for parity with the existing record convention.
+ defp normalize_org_metadata(meta) do
+ meta
+ |> rename_key("filetags", "tags")
+ |> Map.update("tags", [], &parse_org_tags_value/1)
+ |> Map.update("links", [], &parse_space_separated/1)
+ end
+
+ defp rename_key(map, from, to) do
+ case Map.pop(map, from) do
+ {nil, m} ->
+ m
+
+ {value, m} ->
+ # Caller-set canonical key wins if both are present.
+ if Map.has_key?(m, to), do: m, else: Map.put(m, to, value)
+ end
+ end
+
+ # Org tags can be `:foo:bar:` (FILETAGS form), `foo bar` (TAGS form),
+ # or already a list (drawer parsing returned a string we treat as
+ # space-separated). Normalize all to a list of strings.
+ defp parse_org_tags_value(nil), do: []
+ defp parse_org_tags_value([]), do: []
+ defp parse_org_tags_value(list) when is_list(list), do: Enum.map(list, &to_string/1)
+
+ defp parse_org_tags_value(str) when is_binary(str) do
+ trimmed = String.trim(str)
+
+ cond do
+ trimmed == "" -> []
+ String.starts_with?(trimmed, ":") -> trimmed |> String.split(":", trim: true)
+ true -> parse_space_separated(trimmed)
end
end
@@ -148,73 +281,15 @@ defmodule Egghead.Record.Parser do
end
end
- defp split_property_drawer(content) do
- trimmed = String.trim_leading(content)
-
- if String.starts_with?(trimmed, ":PROPERTIES:") do
- case String.split(trimmed, ":END:", parts: 2) do
- [drawer, rest] ->
- props = parse_drawer_properties(drawer)
- {:ok, props, String.trim(rest)}
-
- _ ->
- :error
- end
- else
- :error
- end
- end
-
- defp parse_drawer_properties(drawer) do
- drawer
- |> String.split("\n")
- |> Enum.reject(&(&1 =~ ~r/^\s*:PROPERTIES:\s*$/))
- |> Enum.reduce(%{}, fn line, acc ->
- case Regex.run(~r/^\s*:(\w+):\s*(.*)$/, String.trim(line)) do
- [_, key, value] ->
- Map.put(acc, String.downcase(key), String.trim(value))
-
- _ ->
- acc
- end
- end)
- end
-
- defp normalize_org_props(props) do
- # Start with all properties (preserves arbitrary keys)
- # Then override known keys that need special handling
- props
- |> Map.put("tags", parse_space_separated(Map.get(props, "tags", "")))
- |> Map.put("links", parse_space_separated(Map.get(props, "links", "")))
- end
-
defp parse_space_separated(nil), do: []
+ defp parse_space_separated([]), do: []
+ defp parse_space_separated(list) when is_list(list), do: Enum.map(list, &to_string/1)
defp parse_space_separated(""), do: []
- defp parse_space_separated(str) do
+ defp parse_space_separated(str) when is_binary(str) do
str |> String.split(~r/\s+/, trim: true)
end
- defp extract_org_title(text) do
- text
- |> String.split("\n")
- |> Enum.find_value(fn line ->
- case Regex.run(~r/^\s*#\+TITLE:\s*(.+)$/i, line) do
- [_, title] -> String.trim(title)
- _ -> nil
- end
- end)
- end
-
- defp extract_org_body(text) do
- lines = String.split(text, "\n")
-
- lines
- |> Enum.reject(&String.match?(&1, ~r/^\s*#\+TITLE:/i))
- |> Enum.join("\n")
- |> String.trim()
- end
-
@doc """
Extracts wikilinks from body text.
diff --git a/lib/egghead/record_store.ex b/lib/egghead/record_store.ex
index 831cfc1..b0399d9 100644
--- a/lib/egghead/record_store.ex
+++ b/lib/egghead/record_store.ex
@@ -21,6 +21,7 @@ defmodule Egghead.RecordStore do
alias Egghead.Index
alias Egghead.Record
+ alias Egghead.Record.OrgWriter
alias Egghead.Record.Parser
# ETS table caching hydrated records by source_path, value
@@ -77,7 +78,11 @@ defmodule Egghead.RecordStore do
end
@doc """
- Creates a new record by writing a Markdown file to the records directory.
+ Creates a new record by writing a file to the records directory.
+
+ Pass `format: :org` to write a `.org` file with org-mode preamble; the
+ default is `:markdown` (`.md` with YAML frontmatter). The default can
+ also be set globally via `default_format:` in `config.yml`.
Returns `{:ok, record}` or `{:error, reason}`.
"""
@@ -244,6 +249,7 @@ defmodule Egghead.RecordStore do
def handle_call({:create_record, attrs}, _from, state) do
id = Map.get(attrs, :id) || Map.get(attrs, "id") || generate_id()
attrs = normalize_attrs(attrs, id)
+ format = resolve_format(attrs["format"])
# Check if already exists in index
case Index.get_record_meta(state.index, id) do
@@ -251,8 +257,8 @@ defmodule Egghead.RecordStore do
{:reply, {:error, :already_exists}, state}
{:error, :not_found} ->
- content = render_markdown(attrs)
- filename = "#{id}.md"
+ content = render_record(attrs, format)
+ filename = "#{id}#{format_extension(format)}"
path = Path.join(state.records_dir, filename)
if File.exists?(path) do
@@ -289,11 +295,16 @@ defmodule Egghead.RecordStore do
normalized = normalize_attrs(attrs, id)
content =
- if body_only_update?(normalized) do
- splice_body(path, normalized["body"])
- else
- merged = merge_record_attrs(existing, normalized)
- render_markdown(merged)
+ cond do
+ body_only_update?(normalized) ->
+ splice_body(path, normalized["body"], existing.format)
+
+ existing.format == :org ->
+ splice_org_metadata(path, normalized)
+
+ true ->
+ merged = merge_record_attrs(existing, normalized)
+ render_markdown(merged)
end
File.write!(path, content)
@@ -897,7 +908,16 @@ defmodule Egghead.RecordStore do
# Replace just the body portion of a file, preserving the raw
# frontmatter exactly as written on disk.
- defp splice_body(path, new_body) do
+ #
+ # For org records there is no separate frontmatter — `#+`-keywords and
+ # property drawers are part of the document. A "body-only" update on
+ # an org record overwrites the whole file with the new body, since
+ # `record.body` *is* the file content.
+ defp splice_body(_path, new_body, :org) do
+ String.trim_trailing(new_body || "") <> "\n"
+ end
+
+ defp splice_body(path, new_body, _format) do
raw = File.read!(path)
case Parser.split_raw(raw) do
@@ -910,6 +930,34 @@ defmodule Egghead.RecordStore do
end
end
+ # Targeted line-splice for an org file's metadata. Reads the file, applies
+ # the attr changes via OrgWriter — which mutates only the affected
+ # `#+`-keyword lines and properties-drawer entries — and returns the new
+ # content. The user's casing, ordering, comments, and unrelated drawer
+ # entries are preserved byte-for-byte.
+ defp splice_org_metadata(path, attrs) do
+ raw = File.read!(path)
+ OrgWriter.splice_metadata(raw, attrs)
+ end
+
+ # Resolve the requested format. Accepts :org, :markdown, "org", "markdown",
+ # or nil (falls back to the configured default). Defaults to :markdown.
+ defp resolve_format(nil) do
+ Application.get_env(:egghead, :default_format, :markdown)
+ end
+
+ defp resolve_format(:org), do: :org
+ defp resolve_format(:markdown), do: :markdown
+ defp resolve_format("org"), do: :org
+ defp resolve_format("markdown"), do: :markdown
+ defp resolve_format(_), do: :markdown
+
+ defp format_extension(:org), do: ".org"
+ defp format_extension(_), do: ".md"
+
+ defp render_record(attrs, :org), do: OrgWriter.new(attrs)
+ defp render_record(attrs, _), do: render_markdown(attrs)
+
# Skip keys for the meta_lines pass. `updated` is filesystem-owned and
# never written back. `created` is omitted from the explicit known-lines
# below so that authored values (preserved in `meta`) flow through the
diff --git a/lib/egghead/tui/markdown_cache.ex b/lib/egghead/tui/markdown_cache.ex
index 70d1d82..ad2001c 100644
--- a/lib/egghead/tui/markdown_cache.ex
+++ b/lib/egghead/tui/markdown_cache.ex
@@ -28,6 +28,7 @@ defmodule Egghead.TUI.MarkdownCache do
use GenServer
alias Egghead.OpenTUI.Markdown
+ alias Egghead.TUI.OrgRender
@table :egghead_markdown_cache
@@ -36,24 +37,32 @@ defmodule Egghead.TUI.MarkdownCache do
@doc """
Render `text` at `width` with `opts`, caching the result. On miss,
- calls `Egghead.OpenTUI.Markdown.render/3`. On cache absence, falls
- through without caching.
+ calls the renderer for the requested format
+ (`:format` opt, default `:markdown`). On cache absence, falls through
+ without caching.
+
+ The cache key includes the format, so a markdown body and an org
+ body with the same text don't collide.
"""
@spec render(String.t(), pos_integer(), keyword()) :: Markdown.rendered()
def render(text, width, opts \\ []) when is_binary(text) and is_integer(width) and width > 0 do
- key = {text, width, opts}
+ format = Keyword.get(opts, :format, :markdown)
+ key = {format, text, width, Keyword.delete(opts, :format)}
case lookup(key) do
{:ok, rows} ->
rows
:miss ->
- rows = Markdown.render(text, width, opts)
+ rows = render_for(format, text, width, Keyword.delete(opts, :format))
put(key, rows)
rows
end
end
+ defp render_for(:org, text, width, opts), do: OrgRender.render(text, width, opts)
+ defp render_for(_, text, width, opts), do: Markdown.render(text, width, opts)
+
@doc "Empty the cache. Useful in tests and on explicit invalidation."
@spec reset() :: :ok
def reset do
diff --git a/lib/egghead/tui/org_render.ex b/lib/egghead/tui/org_render.ex
new file mode 100644
index 0000000..49830d0
--- /dev/null
+++ b/lib/egghead/tui/org_render.ex
@@ -0,0 +1,393 @@
+defmodule Egghead.TUI.OrgRender do
+ @moduledoc """
+ Render an org-mode body to the same row/span shape that
+ `Egghead.OpenTUI.Markdown.render/3` produces, so any TUI screen that
+ consumes its output (records preview, chat transcript) can display org
+ records without a separate wiring path.
+
+ This module lives in the application layer (`Egghead.TUI.*`) rather
+ than the framework layer (`Egghead.OpenTUI.*`) because it depends on
+ `Egghead.Record.OrgParser`, which the framework must not reference.
+
+ ## Why a dedicated renderer instead of "convert to markdown"
+
+ Org documents carry structure that markdown has no equivalent for —
+ TODO/DONE keywords, priorities, headline tags, property drawers,
+ `#+`-keywords, active vs. inactive timestamps, source-block markers.
+ Converting through Earmark would erase those artifacts and give org
+ users a generic view of their own files.
+
+ This renderer preserves them visibly: `*` stars stay on headlines,
+ TODO is colored as a keyword, drawers are dimmed but visible, source
+ blocks keep their `#+BEGIN_SRC`/`#+END_SRC` lines. The point is that
+ what's on disk is what shows in the preview.
+
+ Output type matches `Egghead.OpenTUI.Markdown.rendered/0` exactly.
+ """
+
+ alias Egghead.OpenTUI.{Attrs, Colors, Markdown}
+ alias Egghead.Record.OrgParser
+
+ @default_max_width 100
+
+ @type rendered :: Markdown.rendered()
+
+ @doc """
+ Built-in theme. Apps can `Map.merge(default_theme(), overrides)` for
+ partial overrides.
+ """
+ @spec default_theme() :: Markdown.theme()
+ def default_theme do
+ %{
+ h1: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ h2: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ h3: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ h4: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ h5: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ h6: %{fg: Colors.heading(), attrs: Attrs.bold()},
+ stars: %{fg: Colors.muted(), attrs: 0},
+ keyword: %{fg: Colors.muted(), attrs: 0},
+ keyword_value: %{fg: :inherit, attrs: 0},
+ todo: %{fg: Colors.link(), attrs: Attrs.bold()},
+ done: %{fg: Colors.muted(), attrs: Attrs.bold()},
+ priority: %{fg: Colors.link(), attrs: Attrs.bold()},
+ tag: %{fg: Colors.muted(), attrs: 0},
+ drawer: %{fg: Colors.muted(), attrs: 0},
+ property_key: %{fg: Colors.muted(), attrs: 0},
+ property_value: %{fg: :inherit, attrs: 0},
+ block_marker: %{fg: Colors.muted(), attrs: 0},
+ code_block: %{fg: Colors.code(), attrs: 0},
+ code_inline: %{fg: Colors.code(), attrs: 0},
+ verbatim: %{fg: Colors.code(), attrs: 0},
+ bold: %{fg: :inherit, attrs: Attrs.bold()},
+ italic: %{fg: :inherit, attrs: Attrs.italic()},
+ underline: %{fg: :inherit, attrs: Attrs.bold()},
+ strikethrough: %{fg: Colors.muted(), attrs: Attrs.strikethrough()},
+ link: %{fg: Colors.link(), attrs: 0},
+ wikilink: %{fg: Colors.link(), attrs: 0},
+ timestamp_active: %{fg: Colors.link(), attrs: 0},
+ timestamp_inactive: %{fg: Colors.muted(), attrs: 0},
+ list_marker: %{fg: :inherit, attrs: 0}
+ }
+ end
+
+ @doc """
+ Render org body content to a list of styled rows.
+
+ Options:
+
+ * `:theme` — override the default theme. Missing keys fall back.
+ * `:max_width` — soft-wrap clamp (default 100).
+ """
+ @spec render(String.t(), pos_integer(), keyword()) :: rendered()
+ def render(body, width, opts \\ [])
+
+ def render(body, width, opts) when is_binary(body) and width > 0 do
+ theme = Map.merge(default_theme(), Keyword.get(opts, :theme, %{}))
+ max_w = Keyword.get(opts, :max_width, @default_max_width)
+ eff_width = min(width, max_w)
+
+ case OrgParser.parse(body) do
+ {:ok, ast} ->
+ Enum.flat_map(ast, &render_block(&1, eff_width, theme))
+
+ _ ->
+ fallback(body)
+ end
+ rescue
+ _ -> fallback(body)
+ end
+
+ defp fallback(body) do
+ body
+ |> String.split("\n")
+ |> Enum.map(fn line -> [Markdown.plain_span(line)] end)
+ end
+
+ # --- Block rendering ---
+
+ defp render_block({:headline, meta, _children}, width, theme) do
+ %{level: level, title: title, keyword: keyword, priority: priority, tags: tags} = meta
+ head_key = String.to_atom("h#{min(level, 6)}")
+ head_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[head_key])
+ star_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:stars])
+
+ stars = String.duplicate("*", level)
+
+ parts =
+ [Markdown.plain_span(stars <> " ", star_ctx)]
+ |> maybe_append_keyword(keyword, theme)
+ |> maybe_append_priority(priority, theme)
+ |> Kernel.++([Markdown.plain_span(title, head_ctx)])
+ |> maybe_append_tags(tags, theme)
+
+ Markdown.wrap_spans(parts, width) ++ [[]]
+ end
+
+ defp render_block({:keyword, %{key: key, value: value}, _}, width, theme) do
+ kw_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:keyword])
+ val_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:keyword_value])
+
+ spans = [
+ Markdown.plain_span("#+#{key}: ", kw_ctx),
+ Markdown.plain_span(value, val_ctx)
+ ]
+
+ Markdown.wrap_spans(spans, width)
+ end
+
+ defp render_block({:property_drawer, _, props}, _width, theme) do
+ drawer_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:drawer])
+ key_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:property_key])
+ val_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:property_value])
+
+ open_row = [Markdown.plain_span(":PROPERTIES:", drawer_ctx)]
+ close_row = [Markdown.plain_span(":END:", drawer_ctx)]
+
+ rows =
+ Enum.map(props, fn {k, v} ->
+ [
+ Markdown.plain_span(":#{k}: ", key_ctx),
+ Markdown.plain_span(v, val_ctx)
+ ]
+ end)
+
+ [open_row | rows] ++ [close_row, []]
+ end
+
+ defp render_block({:src_block, %{language: lang}, content}, width, theme) do
+ marker_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:block_marker])
+ code_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:code_block])
+ label = if lang, do: " " <> lang, else: ""
+
+ code_lines =
+ content
+ |> String.split("\n")
+ |> Enum.map(fn line ->
+ truncated = String.slice(line, 0, max(1, width - 2))
+ [Markdown.plain_span(" " <> truncated, code_ctx)]
+ end)
+
+ [[Markdown.plain_span("#+BEGIN_SRC" <> label, marker_ctx)]] ++
+ code_lines ++
+ [[Markdown.plain_span("#+END_SRC", marker_ctx)], []]
+ end
+
+ defp render_block({:example_block, _, content}, width, theme) do
+ marker_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:block_marker])
+ code_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:code_block])
+
+ lines =
+ content
+ |> String.split("\n")
+ |> Enum.map(fn line ->
+ truncated = String.slice(line, 0, max(1, width - 2))
+ [Markdown.plain_span(" " <> truncated, code_ctx)]
+ end)
+
+ [[Markdown.plain_span("#+BEGIN_EXAMPLE", marker_ctx)]] ++
+ lines ++
+ [[Markdown.plain_span("#+END_EXAMPLE", marker_ctx)], []]
+ end
+
+ defp render_block({:quote_block, _, inner}, width, theme) do
+ inner_width = max(1, width - 2)
+ quote_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:drawer])
+
+ inner
+ |> Enum.flat_map(&render_block(&1, inner_width, theme))
+ |> Enum.map(fn
+ [] -> [Markdown.plain_span(" │", quote_ctx)]
+ row -> [Markdown.plain_span(" │ ", quote_ctx) | row]
+ end)
+ end
+
+ defp render_block({:plain_list, _, items}, width, theme) do
+ rows =
+ Enum.flat_map(items, fn item -> render_list_item(item, width, theme) end)
+
+ rows ++ [[]]
+ end
+
+ defp render_block({:paragraph, _, inline}, width, theme) do
+ spans = render_inline(inline, Markdown.default_ctx(), theme)
+ Markdown.wrap_spans(spans, width) ++ [[]]
+ end
+
+ defp render_block(text, width, _theme) when is_binary(text) do
+ Markdown.wrap_spans([Markdown.plain_span(text)], width)
+ end
+
+ defp render_block(_, _, _), do: []
+
+ # --- List items ---
+
+ defp render_list_item({:list_item, %{checkbox: checkbox, bullet: bullet}, inline}, width, theme) do
+ marker_ctx = Markdown.apply_style(Markdown.default_ctx(), theme[:list_marker])
+
+ marker =
+ case checkbox do
+ :checked -> "☑ "
+ :partial -> "☐ "
+ :unchecked -> "☐ "
+ _ -> bullet_marker(bullet)
+ end
+
+ prefix = " " <> marker
+ indent_w = String.length(prefix)
+ inner_width = max(1, width - indent_w)
+ indent = String.duplicate(" ", indent_w)
+ spans = render_inline(inline, Markdown.default_ctx(), theme)
+ rows = Markdown.wrap_spans(spans, inner_width)
+
+ case rows do
+ [] ->
+ [[Markdown.plain_span(prefix, marker_ctx)]]
+
+ [first | rest] ->
+ [
+ [Markdown.plain_span(prefix, marker_ctx) | first]
+ | Enum.map(rest, fn row -> [Markdown.plain_span(indent, marker_ctx) | row] end)
+ ]
+ end
+ end
+
+ defp render_list_item(_, _, _), do: []
+
+ defp bullet_marker(b) when is_binary(b) do
+ cond do
+ String.match?(b, ~r/^\d+[.)]/) -> b <> " "
+ true -> "· "
+ end
+ end
+
+ defp bullet_marker(_), do: "· "
+
+ # --- Inline elements ---
+
+ defp render_inline(elements, ctx, theme) when is_list(elements) do
+ Enum.flat_map(elements, &render_inline_one(&1, ctx, theme))
+ end
+
+ defp render_inline_one({:text, text}, ctx, _theme),
+ do: [Markdown.plain_span(normalize_inline_text(text), ctx)]
+
+ defp render_inline_one({:bold, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:bold])
+ [Markdown.plain_span("*" <> text <> "*", styled)]
+ end
+
+ defp render_inline_one({:italic, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:italic])
+ [Markdown.plain_span("/" <> text <> "/", styled)]
+ end
+
+ defp render_inline_one({:code, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:code_inline])
+ [Markdown.plain_span("~" <> text <> "~", styled)]
+ end
+
+ defp render_inline_one({:verbatim, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:verbatim])
+ [Markdown.plain_span("=" <> text <> "=", styled)]
+ end
+
+ defp render_inline_one({:underline, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:underline])
+ [Markdown.plain_span("_" <> text <> "_", styled)]
+ end
+
+ defp render_inline_one({:strikethrough, text}, ctx, theme) do
+ styled = Markdown.apply_style(ctx, theme[:strikethrough])
+ [Markdown.plain_span("+" <> text <> "+", styled)]
+ end
+
+ defp render_inline_one({:link, %{target: target, display: display}}, ctx, theme) do
+ text =
+ if display in [nil, ""] do
+ "[[#{target}]]"
+ else
+ "[[#{target}][#{display}]]"
+ end
+
+ style_key = if external?(target), do: :link, else: :wikilink
+ styled = Markdown.apply_style(ctx, theme[style_key])
+
+ span = Markdown.plain_span(text, styled)
+
+ span =
+ if style_key == :wikilink,
+ do: %{span | link: {:wikilink, strip_fragment(target)}},
+ else: span
+
+ [span]
+ end
+
+ defp render_inline_one(
+ {:timestamp, %{type: type, date: date, day: day, time: time}},
+ ctx,
+ theme
+ ) do
+ {open, close} = if type == :active, do: {"<", ">"}, else: {"[", "]"}
+
+ body =
+ [date, day, time]
+ |> Enum.reject(&(&1 == nil or &1 == ""))
+ |> Enum.join(" ")
+
+ style_key = if type == :active, do: :timestamp_active, else: :timestamp_inactive
+ styled = Markdown.apply_style(ctx, theme[style_key])
+ [Markdown.plain_span(open <> body <> close, styled)]
+ end
+
+ defp render_inline_one(text, ctx, _theme) when is_binary(text),
+ do: [Markdown.plain_span(text, ctx)]
+
+ defp render_inline_one(_, _ctx, _theme), do: []
+
+ # --- Helpers ---
+
+ defp maybe_append_keyword(parts, nil, _theme), do: parts
+
+ defp maybe_append_keyword(parts, keyword, theme) do
+ style_key = if String.upcase(keyword) == "DONE", do: :done, else: :todo
+ styled = Markdown.apply_style(Markdown.default_ctx(), theme[style_key])
+ parts ++ [Markdown.plain_span(keyword <> " ", styled)]
+ end
+
+ defp maybe_append_priority(parts, nil, _theme), do: parts
+
+ defp maybe_append_priority(parts, priority, theme) do
+ styled = Markdown.apply_style(Markdown.default_ctx(), theme[:priority])
+ parts ++ [Markdown.plain_span("[#" <> priority <> "] ", styled)]
+ end
+
+ defp maybe_append_tags(parts, [], _theme), do: parts
+
+ defp maybe_append_tags(parts, tags, theme) when is_list(tags) do
+ styled = Markdown.apply_style(Markdown.default_ctx(), theme[:tag])
+ text = " :" <> Enum.join(tags, ":") <> ":"
+ parts ++ [Markdown.plain_span(text, styled)]
+ end
+
+ defp external?(target) do
+ String.contains?(target, "://") or
+ String.starts_with?(target, "/") or
+ String.starts_with?(target, "mailto:") or
+ String.starts_with?(target, "file:")
+ end
+
+ defp strip_fragment(target) do
+ case String.split(target, "#", parts: 2) do
+ [t, _] -> t
+ [t] -> t
+ end
+ end
+
+ # Inline text inside a paragraph is single-logical-line. If a stray
+ # `\n` slipped through (e.g. from a multi-line block that fell back
+ # to a literal-text span), turn it into a space so the row renders
+ # without word concatenation. Word-wrap is wrap_spans' job, not
+ # the text content's.
+ defp normalize_inline_text(text) when is_binary(text), do: String.replace(text, "\n", " ")
+end
diff --git a/lib/egghead/tui/records/model.ex b/lib/egghead/tui/records/model.ex
index c84e58a..60c06ef 100644
--- a/lib/egghead/tui/records/model.ex
+++ b/lib/egghead/tui/records/model.ex
@@ -281,7 +281,8 @@ defmodule Egghead.TUI.Records.Model do
if model.preview_rendered != nil and model.preview_rendered_width == width do
model
else
- body_rows = MarkdownCache.render(model.selected_body, width)
+ format = (model.selected_record && model.selected_record.format) || :markdown
+ body_rows = MarkdownCache.render(model.selected_body, width, format: format)
forward_targets = forward_targets_for(model.selected_record)
backlink_records = backlinks_for(model.selected_id)
diff --git a/lib/egghead/web/live/app_live.ex b/lib/egghead/web/live/app_live.ex
index 39f736e..39d34a5 100644
--- a/lib/egghead/web/live/app_live.ex
+++ b/lib/egghead/web/live/app_live.ex
@@ -9,6 +9,7 @@ defmodule Egghead.Web.AppLive do
import Egghead.Web.Components.Window
alias Egghead.Web.MarkdownHTML
+ alias Egghead.Web.OrgHTML
alias Egghead.TUI.Records.Slug
@impl true
@@ -605,11 +606,7 @@ defmodule Egghead.Web.AppLive do
id ->
case Egghead.get_record(id) do
{:ok, record} ->
- html =
- MarkdownHTML.render(record.body || "",
- link_fn: &"/records/#{&1}",
- exists_fn: &record_exists?/1
- )
+ html = render_record_body(record)
backlinks = Egghead.find_backlinks(id)
@@ -644,6 +641,23 @@ defmodule Egghead.Web.AppLive do
end
end
+ # Dispatch on the record's on-disk format. Org records use `OrgHTML` so the
+ # rendered output preserves headline stars, TODO keywords, drawer entries,
+ # and `#+BEGIN_SRC` markers — what an emacs user expects to see.
+ defp render_record_body(%{format: :org, body: body}) do
+ OrgHTML.render(body || "",
+ link_fn: &"/records/#{&1}",
+ exists_fn: &record_exists?/1
+ )
+ end
+
+ defp render_record_body(%{body: body}) do
+ MarkdownHTML.render(body || "",
+ link_fn: &"/records/#{&1}",
+ exists_fn: &record_exists?/1
+ )
+ end
+
defp build_file_tree(records) do
records
|> Enum.group_by(fn r ->
@@ -2055,7 +2069,8 @@ defmodule Egghead.Web.AppLive do
phx-hook="YjsEditor"
phx-update="ignore"
data-record-id={@selected_record.id}
- class="record-editor"
+ data-format={to_string(@selected_record.format)}
+ class={"record-editor record-editor-#{@selected_record.format}"}
>
diff --git a/lib/egghead/web/org_html.ex b/lib/egghead/web/org_html.ex
new file mode 100644
index 0000000..43bda99
--- /dev/null
+++ b/lib/egghead/web/org_html.ex
@@ -0,0 +1,293 @@
+defmodule Egghead.Web.OrgHTML do
+ @moduledoc """
+ Renders an org-mode body to HTML, walking the `Egghead.Record.OrgParser`
+ AST and emitting HTML that **looks like org-mode**. Headlines keep their
+ stars, TODO keywords render as styled badges, property drawers show as
+ visible `
" <> escape(body) <> "" + end + rescue + _ -> "
" <> escape(body) <> "" + end + + defp default_link(target), do: "/records/#{target}" + + # --- Block-level nodes --- + + defp render_block({:headline, meta, _children}, _link_fn, _exists_fn) do + %{level: level, title: title, keyword: keyword, priority: priority, tags: tags} = meta + tag = "h#{min(level, 6)}" + stars = String.duplicate("*", level) + + keyword_html = + if keyword, + do: + "#{escape(keyword)}", + else: "" + + priority_html = + if priority, + do: "[#" <> escape(priority) <> "]", + else: "" + + tags_html = + case tags do + [] -> + "" + + list -> + Enum.map_join(list, "", fn t -> + "" <> escape(t) <> "" + end) + |> then(&"") + end + + parts = + [ + "#{stars}", + keyword_html, + priority_html, + "" <> escape(title) <> "", + tags_html + ] + |> Enum.reject(&(&1 == "")) + |> Enum.join(" ") + + "<#{tag} class=\"org-headline\" data-level=\"#{level}\">#{parts}#{tag}>\n" + end + + defp render_block({:keyword, %{key: key, value: value}, _}, _link_fn, _exists_fn) do + # Keywords like #+TITLE, #+AUTHOR, #+DATE, #+OPTIONS appear visibly so + # the rendered view shows what the file says. The site CSS can hide + # the title keyword if a heading is preferred. + "
" <>
+ escape(content) <>
+ "" <>
+ "" <> + escape(content) <> + "" <> + "
#{body}\n" + end + + defp render_block({:plain_list, _, items}, link_fn, exists_fn) do + list_tag = + case items do + [{:list_item, %{bullet: bullet}, _} | _] when is_binary(bullet) -> + if String.match?(bullet, ~r/^\d/), do: "ol", else: "ul" + + _ -> + "ul" + end + + body = + Enum.map_join(items, "", fn item -> render_list_item(item, link_fn, exists_fn) end) + + "<#{list_tag} class=\"org-list\">#{body}#{list_tag}>\n" + end + + defp render_block({:paragraph, _, inline}, link_fn, exists_fn) do + "
" <> + render_inline(inline, link_fn, exists_fn) <> + "
\n" + end + + # Fallback for any tagged block we don't explicitly handle. + defp render_block({_tag, _meta, _children}, _link_fn, _exists_fn), do: "" + defp render_block(text, _link_fn, _exists_fn) when is_binary(text), do: escape(text) + defp render_block(_, _, _), do: "" + + defp render_list_item({:list_item, %{checkbox: checkbox}, inline}, link_fn, exists_fn) do + {prefix, classes} = + case checkbox do + :checked -> + {" ", " org-task org-task-done"} + + :partial -> + {" ", " org-task org-task-partial"} + + :unchecked -> + {" ", " org-task org-task-todo"} + + _ -> + {"", ""} + end + + "" <> escape(text) <> ""
+
+ defp render_inline_one({:verbatim, text}, _, _),
+ do: "" <> escape(text) <> ""
+
+ defp render_inline_one({:underline, text}, _, _),
+ do: "" <> escape(text) <> ""
+
+ defp render_inline_one({:strikethrough, text}, _, _),
+ do: "IO.puts("hi")|
+ end
+
+ test "no language still shows markers" do
+ html =
+ OrgHTML.render("""
+ #+BEGIN_SRC
+ bare
+ #+END_SRC
+ """)
+
+ assert html =~ "#+BEGIN_SRC"
+ assert html =~ "#+END_SRC"
+ assert html =~ "bare"
+ end
+ end
+
+ describe "links" do
+ test "internal target becomes a wikilink with data-wikilink" do
+ html = OrgHTML.render("See [[rec_other][the other]] please.")
+ assert html =~ ~s|class="org-link wikilink"|
+ assert html =~ ~s|data-wikilink="rec_other"|
+ assert html =~ ~s|href="/records/rec_other"|
+ assert html =~ ">the other"
+ end
+
+ test "missing wikilink target gets the 'missing' class" do
+ html = OrgHTML.render("[[ghost]]", exists_fn: fn _ -> false end)
+ assert html =~ ~s|class="org-link wikilink missing"|
+ end
+
+ test "external https link is a plain anchor" do
+ html = OrgHTML.render("[[https://example.com][example]]")
+ assert html =~ ~s|class="org-link"|
+ assert html =~ ~s|href="https://example.com"|
+ refute html =~ "data-wikilink"
+ end
+ end
+
+ describe "timestamps" do
+ test "active timestamp keeps angle brackets" do
+ html = OrgHTML.render("Meeting at <2026-04-30 Thu 10:00> sharp.")
+ assert html =~ ~s|