From d05e24e4ce25c2e94eda02146e25d10f5f6104c7 Mon Sep 17 00:00:00 2001 From: Mark Wunsch Date: Thu, 30 Apr 2026 14:19:10 -0400 Subject: [PATCH] feat: org-mode as a first-class record format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Egghead now reads and writes .org files alongside .md files. Both formats parse into the same Record shape, share the same id and link graph, and route through the same agent / MCP / TUI / web surfaces. Parser & writer - Record.Parser detects .org via #+-keywords / property drawer / extension; parse_org keeps the body byte-faithful and extracts metadata from #+TITLE/#+AUTHOR/#+FILETAGS/:ID:/:LINKS:/:CLASS: - Record.OrgWriter generates skeletons for new records and line-splices field updates on existing files - RecordStore.create_record accepts format: :org (or "org"); update_record preserves the existing on-disk format Rendering - Web: Egghead.Web.OrgHTML walks the OrgParser AST so headlines keep their stars, TODO/DONE render as styled badges, drawers render as
, source blocks keep their #+BEGIN_SRC markers - TUI: Egghead.TUI.OrgRender mirrors the same fidelity in row/span output; OpenTUI.Markdown.wrap_spans is now public so any renderer producing the same span shape can reuse it - MarkdownCache becomes format-aware via a :format key - LiveView dispatches on record.format; CSS adds .org-* styles; the CodeMirror editor disables markdown grammar for .org records Agents in org-mode - Record.Agent.from strips the file-level keyword block and the first :PROPERTIES: drawer from the body before passing it to the model, so an org agent's prompt is the prose part only - Capability.parse now delegates string input to the existing parse_grant_spec/1, so an org agent's :CAPABILITIES: drawer accepts the same compact form `egghead agents grant` accepts: records.read net.get{hosts=[a.example.com,b.example.com]} - Capability.tokenize is bracket-aware and public so the agent record projection can pre-tokenize without naïve comma split MCP, config, defaults - egghead_create gains a `format` field; egghead_get returns the record's format - Config.default_format toggles the default for new records; documented in the Configuration guide alongside default_model Docs - New site/content/guides/org-mode.md (Integrations) — points at the existing record / agent / capability surfaces and explains the org-specific bits (frontmatter mapping, disposition projection, what isn't implemented). Records guide gains a one-line cross-reference Tests (~70 new across writer round-trip, byte-stability, web HTML fidelity, TUI rendering, capability syntax, MCP create/update, and the org disposition projection) Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/egghead.ex | 3 + lib/egghead/application.ex | 1 + lib/egghead/capability.ex | 62 ++- lib/egghead/config.ex | 17 + lib/egghead/mcp/handler.ex | 25 +- lib/egghead/open_tui/markdown.ex | 16 +- lib/egghead/record/agent.ex | 19 +- lib/egghead/record/org_parser.ex | 67 +++- lib/egghead/record/org_writer.ex | 344 ++++++++++++++++ lib/egghead/record/parser.ex | 231 +++++++---- lib/egghead/record_store.ex | 66 +++- lib/egghead/tui/markdown_cache.ex | 17 +- lib/egghead/tui/org_render.ex | 393 +++++++++++++++++++ lib/egghead/tui/records/model.ex | 3 +- lib/egghead/web/live/app_live.ex | 27 +- lib/egghead/web/org_html.ex | 293 ++++++++++++++ priv/static/assets/app.css | 152 +++++++ priv/static/assets/app.js | 2 + priv/static/assets/editor.js | 12 +- site/content/guides/configuration.md | 6 + site/content/guides/org-mode.md | 114 ++++++ site/content/guides/records.md | 20 +- test/egghead/capability_test.exs | 43 ++ test/egghead/record/org_disposition_test.exs | 129 ++++++ test/egghead/record/org_round_trip_test.exs | 250 ++++++++++++ test/egghead/record/org_writer_test.exs | 253 ++++++++++++ test/egghead/tui/markdown_cache_test.exs | 19 + test/egghead/tui/org_render_test.exs | 200 ++++++++++ test/egghead/web/org_html_test.exs | 234 +++++++++++ test/mcp_handler_test.exs | 35 ++ 30 files changed, 2922 insertions(+), 131 deletions(-) create mode 100644 lib/egghead/record/org_writer.ex create mode 100644 lib/egghead/tui/org_render.ex create mode 100644 lib/egghead/web/org_html.ex create mode 100644 site/content/guides/org-mode.md create mode 100644 test/egghead/record/org_disposition_test.exs create mode 100644 test/egghead/record/org_round_trip_test.exs create mode 100644 test/egghead/record/org_writer_test.exs create mode 100644 test/egghead/tui/org_render_test.exs create mode 100644 test/egghead/web/org_html_test.exs 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 `
` blocks, source blocks are framed by their `#+BEGIN_SRC` / + `#+END_SRC` markers, timestamps keep their angle/square brackets, and + `[[target]]` links round-trip with the `data-wikilink` attribute that + the LiveView already understands. + + This is **not** a markdown renderer in disguise. The emitted HTML + preserves the structural artifacts of an org document so org-mode users + recognise their files on the web. Class names use the `org-` prefix so + the site CSS can theme them independently of the markdown renderer's + output. + + ## Wikilink contract + + Org `[[target][display]]` links whose target looks like a record id + (no `://`, no leading `/`) are emitted as `` so the existing client-side navigation in + `Egghead.Web.Live.AppLive` works on org records without a separate path. + + External org links (e.g. `[[https://…]]`) emit a normal ``. + """ + + alias Egghead.Record.OrgParser + + @doc """ + Render an org-mode body string to HTML. + + Accepts the same `:link_fn` / `:exists_fn` options as + `Egghead.Web.MarkdownHTML.render/2` so wikilink hrefs and + missing-target classes line up across formats. + """ + @spec render(String.t(), keyword()) :: String.t() + def render(body, opts \\ []) when is_binary(body) do + link_fn = Keyword.get(opts, :link_fn, &default_link/1) + exists_fn = Keyword.get(opts, :exists_fn, fn _ -> true end) + + case OrgParser.parse(body) do + {:ok, ast} -> + ast + |> Enum.map(&render_block(&1, link_fn, exists_fn)) + |> IO.iodata_to_binary() + + _ -> + "
" <> 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(&"#{&1}") + end + + parts = + [ + "#{stars}", + keyword_html, + priority_html, + "" <> escape(title) <> "", + tags_html + ] + |> Enum.reject(&(&1 == "")) + |> Enum.join(" ") + + "<#{tag} class=\"org-headline\" data-level=\"#{level}\">#{parts}\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_attr(key) <> + "\">#+" <> + escape(key) <> + ": " <> + escape(value) <> + "
\n" + end + + defp render_block({:property_drawer, _, props}, _link_fn, _exists_fn) do + body = + Enum.map_join(props, "", fn {k, v} -> + "
:" <> + escape(k) <> + ": " <> escape(v) <> "
" + end) + + "
" <> + "
:PROPERTIES:
" <> + body <> + "
:END:
" <> + "
\n" + end + + defp render_block({:src_block, %{language: lang}, content}, _link_fn, _exists_fn) do + lang_attr = if lang, do: " data-language=\"#{escape_attr(lang)}\"", else: "" + lang_label = if lang, do: " " <> escape(lang), else: "" + code_class = if lang, do: " class=\"language-#{escape_attr(lang)}\"", else: "" + + "
" <> + "
#+BEGIN_SRC#{lang_label}
" <> + "
" <>
+      escape(content) <>
+      "
" <> + "
#+END_SRC
" <> + "
\n" + end + + defp render_block({:example_block, _, content}, _link_fn, _exists_fn) do + "
" <> + "
#+BEGIN_EXAMPLE
" <> + "
" <>
+      escape(content) <>
+      "
" <> + "
#+END_EXAMPLE
" <> + "
\n" + end + + defp render_block({:quote_block, _, inner}, link_fn, exists_fn) do + body = + inner + |> Enum.map(&render_block(&1, link_fn, exists_fn)) + |> IO.iodata_to_binary() + + "
#{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}\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 + + "
  • #{prefix}#{render_inline(inline, link_fn, exists_fn)}
  • " + end + + # --- Inline elements --- + + defp render_inline(elements, link_fn, exists_fn) when is_list(elements) do + elements + |> Enum.map(&render_inline_one(&1, link_fn, exists_fn)) + |> IO.iodata_to_binary() + end + + defp render_inline_one({:text, text}, _, _), do: escape(text) + defp render_inline_one({:bold, text}, _, _), do: "" <> escape(text) <> "" + defp render_inline_one({:italic, text}, _, _), do: "" <> escape(text) <> "" + + defp render_inline_one({:code, text}, _, _), + do: "" <> 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: "" <> escape(text) <> "" + + defp render_inline_one({:link, %{target: target, display: display}}, link_fn, exists_fn) do + display_text = display || target + + cond do + external?(target) -> + "
    + escape_attr(target) <> + "\">" <> escape(display_text) <> "" + + true -> + href = link_fn.(target) + exists? = exists_fn.(target) + cls = if exists?, do: "org-link wikilink", else: "org-link wikilink missing" + + " + escape_attr(href) <> + "\" data-wikilink=\"" <> + escape_attr(target) <> + "\" data-phx-link=\"patch\" data-phx-link-state=\"push\">" <> + escape(display_text) <> + "" + end + end + + defp render_inline_one({:timestamp, %{type: type, date: date, day: day, time: time}}, _, _) do + {open, close} = if type == :active, do: {"<", ">"}, else: {"[", "]"} + + parts = + [date, day, time] + |> Enum.reject(&(&1 == nil or &1 == "")) + |> Enum.join(" ") + + "" + end + + defp render_inline_one(text, _, _) when is_binary(text), do: escape(text) + defp render_inline_one(_, _, _), do: "" + + 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 + + # --- Escaping --- + + defp escape(text) when is_binary(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + end + + defp escape(other), do: escape(to_string(other)) + + defp escape_attr(text), do: escape(text) +end diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css index 8c9ba3c..aa52d96 100644 --- a/priv/static/assets/app.css +++ b/priv/static/assets/app.css @@ -1461,6 +1461,158 @@ details[open] > .properties-summary::before { content: "▾"; } content: "~~"; color: var(--syntax); font-family: var(--font-mono); font-size: 0.8em; } +/* ---- Org-mode body ---- */ +/* Mirrors .markdown-body in tone but preserves org-specific artifacts — + headline stars, TODO/DONE keywords, property drawers, #+BEGIN_SRC + markers — so org-mode users see their files the way they expect. */ + +.org-body { + font-family: var(--font-body); + font-size: 16px; + line-height: 1.7; + color: var(--fg); +} + +.org-body .org-headline { + font-family: var(--font-display); + color: var(--heading); + line-height: 1.3; + margin: 1.4em 0 0.4em; +} +.org-body .org-headline[data-level="1"] { font-size: 24px; font-weight: 300; } +.org-body .org-headline[data-level="2"] { font-size: 20px; font-weight: 300; } +.org-body .org-headline[data-level="3"] { font-size: 18px; font-weight: 400; } +.org-body .org-headline[data-level="4"] { font-size: 16px; font-weight: 400; } + +.org-body .org-stars { + color: var(--syntax); + font-family: var(--font-mono); + font-weight: 400; + font-size: 0.7em; + margin-right: 0.3em; +} + +.org-body .org-todo { + font-family: var(--font-mono); + font-size: 0.78em; + font-weight: 600; + padding: 1px 6px; + margin-right: 0.4em; + background: var(--code-bg); + letter-spacing: 0.04em; +} +.org-body .org-todo-todo { color: var(--wikilink); } +.org-body .org-todo-done { color: var(--muted); text-decoration: line-through; } +.org-body .org-todo-next { color: var(--wikilink); } +.org-body .org-todo-waiting { color: var(--muted); } +.org-body .org-todo-cancelled { color: var(--muted); text-decoration: line-through; } +.org-body .org-todo-hold { color: var(--muted); } + +.org-body .org-priority { + color: var(--danger); + font-family: var(--font-mono); + font-size: 0.78em; + font-weight: 600; + margin-right: 0.4em; +} + +.org-body .org-tags { margin-left: 0.6em; } +.org-body .org-tag { + font-family: var(--font-mono); + font-size: 0.72em; + color: var(--muted); + background: var(--code-bg); + padding: 1px 5px; + margin-left: 4px; +} + +.org-body .org-keyword { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.85em; + margin: 0.2em 0; +} +.org-body .org-keyword-key { color: var(--syntax); } +.org-body .org-keyword-value { color: var(--fg); } + +.org-body .org-property-drawer { + border-left: 2px solid var(--chrome-lo); + background: var(--code-bg); + padding: 6px 10px; + margin: 0.6em 0; + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--muted); +} +.org-body .org-drawer-marker { color: var(--syntax); } +.org-body .org-property-key { color: var(--syntax); } +.org-body .org-property-value { color: var(--fg); } +.org-body .org-property { display: block; } + +.org-body .org-src-block { + margin: 0.8em 0; +} +.org-body .org-block-marker { + color: var(--syntax); + font-family: var(--font-mono); + font-size: 12px; +} +.org-body pre.org-src, +.org-body pre.org-example { + background: var(--code-bg); + padding: 12px 14px; + overflow-x: auto; + margin: 0; + box-shadow: + inset 1px 1px 0 var(--chrome-lo), + inset -1px -1px 0 var(--chrome-hi); +} +.org-body pre.org-src code, +.org-body pre.org-example code { + background: none; + padding: 0; + font-size: 14px; + line-height: 1.5; + font-family: var(--font-mono); +} + +.org-body .org-paragraph { margin-bottom: 0.8em; } + +.org-body .org-list { margin: 0.5em 0 0.8em 1.5em; } +.org-body .org-list-item { margin-bottom: 0.2em; } +.org-body .org-task input { margin-right: 6px; } +.org-body .org-task-done { color: var(--muted); } + +.org-body code.org-code, +.org-body code.org-verbatim { + font-family: var(--font-mono); + font-size: 0.88em; + color: var(--code-bg); + background: var(--fg); + padding: 0 3px; +} +.org-body code.org-code { background: var(--code-bg); color: var(--fg); } +.org-body code.org-verbatim { background: var(--code-bg); color: var(--fg); } + +.org-body .org-underline { text-decoration: underline; } + +.org-body a.org-link { + color: var(--wikilink); + text-decoration: none; + font-weight: 500; +} +.org-body a.org-link:hover { text-decoration: underline; } +.org-body a.org-link.wikilink::before { content: "[["; color: var(--wikilink); font-family: var(--font-mono); font-weight: 400; font-size: 0.8em; } +.org-body a.org-link.wikilink::after { content: "]]"; color: var(--wikilink); font-family: var(--font-mono); font-weight: 400; font-size: 0.8em; } +.org-body a.org-link.missing { color: var(--danger); } + +.org-body time.org-ts { + font-family: var(--font-mono); + font-size: 0.85em; +} +.org-body time.org-ts-active { color: var(--wikilink); } +.org-body time.org-ts-inactive { color: var(--muted); } + /* ---- CodeMirror editor (record body) ---- */ .record-editor { diff --git a/priv/static/assets/app.js b/priv/static/assets/app.js index 0deb26a..75e2894 100644 --- a/priv/static/assets/app.js +++ b/priv/static/assets/app.js @@ -962,7 +962,9 @@ Hooks.YjsEditor = { async mounted() { const { createEditor } = await import("./editor.js"); const recordId = this.el.dataset.recordId; + const format = this.el.dataset.format; this._editor = createEditor(this.el, recordId, { + format, navigate: (target) => { window.liveSocket.redirect(`/records/${target}`); }, diff --git a/priv/static/assets/editor.js b/priv/static/assets/editor.js index 504f4c3..5e9747b 100644 --- a/priv/static/assets/editor.js +++ b/priv/static/assets/editor.js @@ -589,7 +589,7 @@ function lingerPluginFor(provider) { // --- Editor factory --- -export function createEditor(element, recordId, { navigate } = {}) { +export function createEditor(element, recordId, { navigate, format } = {}) { const ydoc = new Y.Doc(); const ytext = ydoc.getText("content"); @@ -603,10 +603,16 @@ export function createEditor(element, recordId, { navigate } = {}) { window.location.href = `/records/${target}`; }); + // Org records skip the markdown grammar — its `*` heading rule would + // mis-bold every org headline and its inline-code rule would chew up + // org `~code~`/`=verbatim=`/`#+KEYWORD:` syntax. Wikilink + URL + // highlighters still work since they're pattern-based, format-agnostic + // overlays on the underlying text. + const isOrg = format === "org"; + const extensions = [ proseTheme, - markdown(), - syntaxHighlighting(markdownHighlight), + ...(isOrg ? [] : [markdown(), syntaxHighlighting(markdownHighlight)]), wikilinkHighlighter, urlHighlighter, mdLinkField, diff --git a/site/content/guides/configuration.md b/site/content/guides/configuration.md index e743d4d..933f408 100644 --- a/site/content/guides/configuration.md +++ b/site/content/guides/configuration.md @@ -85,6 +85,12 @@ default_model: anthropic/claude-sonnet-4-6 # Default chat-room id. Created on demand. default_room: default +# Format for newly created records — `markdown` (the default) or `org`. +# Existing files keep their on-disk format regardless. Setting this to +# `org` only changes the extension and writer used when Egghead creates +# a new file. See [Org-mode]({{< ref "org-mode" >}}). +default_format: markdown + # Web server and MCP HTTP endpoint. web: port: 4000 diff --git a/site/content/guides/org-mode.md b/site/content/guides/org-mode.md new file mode 100644 index 0000000..99b44ed --- /dev/null +++ b/site/content/guides/org-mode.md @@ -0,0 +1,114 @@ +--- +title: Org-mode +section: Integrations +weight: 34 +summary: Egghead reads and writes org-mode files alongside Markdown ones. +--- + +A record can be a Markdown file or an [org-mode](https://orgmode.org/) +file. Both live in the same store, share the same id and link +graph, and parse into the same shape. The choice is per-file; +`.md` and `.org` records sit in the same directory and link to +each other. + +Org support exists so an Emacs user with an existing +[org-roam](https://www.orgroam.com/), +[Denote](https://protesilaos.com/emacs/denote), or +[Logseq](https://logseq.com/) directory can run Egghead against +their notes without converting anything. + +## Setup + +Point `records_dir` at the directory and, if you want records +created by Egghead itself to be `.org` rather than `.md`, set +`default_format`: + +```yaml +records_dir: ~/org/roam +default_format: org +``` + +`default_format` only chooses the extension and writer for new +records. Existing files keep their on-disk format. The full +configuration schema is documented in +[Configuration]({{< ref "configuration" >}}). + +## Frontmatter mapping + +Org has no separate frontmatter section; `#+`-keywords and the +file-level `:PROPERTIES:` drawer are part of the document. +Egghead reads them in place. + +| Org syntax | Record field | +|---------------------------------------|--------------| +| `#+TITLE:` | `title` | +| `#+AUTHOR:` | `author` | +| `#+TAGS:` or `#+FILETAGS:` | `tags` | +| `:ID:` (file-level drawer) | `id` | +| `:LINKS:` | `links` | +| `:CLASS:` | `class` | +| `[[target]]` / `[[target][display]]` | wikilink | +| `* Headlines` | outline | + +The drawer must come before the first headline to count as +file-level; drawers attached to subtrees stay headline-local. +Anything Egghead doesn't recognize — `#+OPTIONS:`, `#+STARTUP:`, +custom drawer keys, `LOGBOOK`, `CLOCK` lines — is preserved on +disk and ignored at the record level. + +The fields themselves carry the same meaning as in +[Records]({{< ref "records" >}}); only the surface syntax +differs. + +## Agents in org-mode + +A `class: agent` org file is an agent. The disposition is the +prose body. On load, Egghead strips the leading `#+`-keyword +block and the file-level `:PROPERTIES:` drawer from the body +before passing it to the model — the file on disk is unchanged, +the model just doesn't see the metadata twice. + +```org +#+TITLE: Archivist +#+FILETAGS: :agent: +:PROPERTIES: +:ID: agents/archivist +:CLASS: agent +:MODEL: anthropic/claude-sonnet-4-6 +:SANDBOX: ~/Work +:CAPABILITIES: records.read records.create net.get{hosts=[orgmode.org,gnu.org]} +:END: + +You are the archivist. ... +``` + +`:CAPABILITIES:` is a single string of grants in the same compact +form `egghead agents grant` accepts. The grant grammar, scope +keys, and `sandbox:` shorthand work exactly as documented in +[Capabilities]({{< ref "capabilities" >}}); this is the +string-valued surface of the same thing. + +## What Egghead doesn't do + +Subtree property drawers are read and preserved on disk but are +not extracted into record metadata. Only the file-level drawer +promotes to record fields. + +Babel evaluation is not implemented. Source blocks are indexed +and rendered; nothing executes. + +Agenda and clock entries (`CLOCK:`, `:LOGBOOK:`) survive on disk +but Egghead does not treat them as structured data. + +`#+INCLUDE:` transclusion is preserved verbatim, not resolved. + +## See also + +- [Records]({{< ref "records" >}}) covers the record model. +- [Agents]({{< ref "agents" >}}) covers the rest of an agent + record's frontmatter. +- [Capabilities]({{< ref "capabilities" >}}) covers the grant + grammar referenced above. +- [Worg](https://orgmode.org/worg/) and the + [org-roam discourse](https://org-roam.discourse.group/) for + background on the conventions Egghead inherits. diff --git a/site/content/guides/records.md b/site/content/guides/records.md index 275fdae..cec107e 100644 --- a/site/content/guides/records.md +++ b/site/content/guides/records.md @@ -5,15 +5,17 @@ weight: 10 summary: A record is a Markdown file in a directory you choose. Optional YAML frontmatter, wikilinks for graph structure, FTS5 for search. --- -A record is a Markdown file (`.md`) or an org-mode file (`.org`) -in your records directory. The default records directory is -`~/.egghead`; you can change it by editing `records_dir` in -`config.yml`. Every other thing Egghead knows about — agents, -skills, saved chat transcripts, deliberation summaries — is also -a record, distinguished by its `class:` frontmatter value. The -full list of classes lives in +A record is a Markdown file in your records directory. The default +records directory is `~/.egghead`; you can change it by editing +`records_dir` in `config.yml`. Every other thing Egghead knows +about — agents, skills, saved chat transcripts, deliberation +summaries — is also a record, distinguished by its `class:` +frontmatter value. The full list of classes lives in [Record classes]({{< ref "record-classes" >}}). +Records can also be org-mode files (`.org`); see +[Org-mode]({{< ref "org-mode" >}}) if that's your workflow. + Most multi-agent frameworks store their state in an internal database. Egghead does not. Your store is a folder you can open in Obsidian, edit in `vim`, sync with iCloud, commit to Git, or @@ -51,9 +53,7 @@ Background worker that removes dead tuples from tables. See ``` The YAML block is fenced by three dashes alone on a line, both -at the top and the bottom. Org-mode files use a `:PROPERTIES:` -... `:END:` drawer with the same field semantics; pick whichever -format you prefer, since both can live in the same store. +at the top and the bottom. ## Frontmatter keys diff --git a/test/egghead/capability_test.exs b/test/egghead/capability_test.exs index eb2498a..dc4f716 100644 --- a/test/egghead/capability_test.exs +++ b/test/egghead/capability_test.exs @@ -62,6 +62,49 @@ defmodule Egghead.CapabilityTest do test "accepts comma/space separated string" do assert [_, _] = Capability.parse("records.read agent.create") end + + test "parses compact spec form for a single-host scope" do + grants = Capability.parse("net.get{hosts=[api.openai.com]}") + assert [%Grant{resource: :net, verb: :get, scope: %{hosts: ["api.openai.com"]}}] = grants + end + + test "parses compact spec form with multiple hosts" do + grants = Capability.parse("net.get{hosts=[*.github.com,api.openai.com]}") + + assert [%Grant{resource: :net, verb: :get, scope: %{hosts: hosts}}] = grants + assert "*.github.com" in hosts + assert "api.openai.com" in hosts + end + + test "parses compact spec form with multiple scope keys" do + grants = Capability.parse("proc.exec{cmds=[rg,jq],in=~/Work}") + + assert [%Grant{resource: :proc, verb: :exec, scope: scope}] = grants + assert scope.cmds == ["rg", "jq"] + assert scope.in == "~/Work" + end + + test "tokenizer respects {} and [] (commas inside don't split tokens)" do + grants = Capability.parse("records.read net.get{hosts=[a,b,c]} agent.create") + kinds = Enum.map(grants, &{&1.resource, &1.verb}) + assert {:records, :read} in kinds + assert {:agent, :create} in kinds + + [net_grant] = Enum.filter(grants, fn g -> g.resource == :net end) + assert net_grant.scope.hosts == ["a", "b", "c"] + end + + test "merges compact-spec and map-style grants for the same resource.verb" do + grants = + Capability.parse([ + "net.get{hosts=[*.github.com]}", + %{"net.get" => %{"hosts" => ["api.openai.com"]}} + ]) + + assert [%Grant{resource: :net, verb: :get, scope: %{hosts: hosts}}] = grants + assert "*.github.com" in hosts + assert "api.openai.com" in hosts + end end describe "check/3 — capability_absent" do diff --git a/test/egghead/record/org_disposition_test.exs b/test/egghead/record/org_disposition_test.exs new file mode 100644 index 0000000..a91c523 --- /dev/null +++ b/test/egghead/record/org_disposition_test.exs @@ -0,0 +1,129 @@ +defmodule Egghead.Record.OrgDispositionTest do + use ExUnit.Case, async: true + + alias Egghead.Record.OrgParser + + describe "body_without_preamble/1" do + test "strips leading #+ keywords" do + content = """ + #+TITLE: Scout + #+AUTHOR: mark + + You are a careful researcher. + """ + + assert OrgParser.body_without_preamble(content) == "You are a careful researcher." + end + + test "strips a leading file-level :PROPERTIES: drawer" do + content = """ + :PROPERTIES: + :ID: agents/scout + :CLASS: agent + :END: + + You are a careful researcher. + """ + + assert OrgParser.body_without_preamble(content) == "You are a careful researcher." + end + + test "strips both #+ keywords and a property drawer" do + content = """ + #+TITLE: Scout + #+FILETAGS: :agent: + :PROPERTIES: + :ID: agents/scout + :CLASS: agent + :CAPABILITIES: records.read + :END: + + You are a careful researcher who looks before leaping. + + * Method + + Always cite sources. + """ + + out = OrgParser.body_without_preamble(content) + assert String.starts_with?(out, "You are a careful researcher") + assert out =~ "* Method" + refute out =~ "#+TITLE:" + refute out =~ ":PROPERTIES:" + end + + test "leaves headline-attached drawers in place" do + content = """ + #+TITLE: Scout + + * First section + :PROPERTIES: + :CUSTOM: keep-me + :END: + + Body. + """ + + out = OrgParser.body_without_preamble(content) + assert out =~ "* First section" + assert out =~ ":CUSTOM: keep-me" + end + + test "no preamble — returns trimmed body" do + content = "* Heading\n\nBody.\n" + assert OrgParser.body_without_preamble(content) == "* Heading\n\nBody." + end + + test "preamble only — empty disposition" do + content = """ + #+TITLE: T + :PROPERTIES: + :ID: x + :END: + """ + + assert OrgParser.body_without_preamble(content) == "" + end + end + + describe "Record.Agent.from/1 disposition projection" do + alias Egghead.Record + alias Egghead.Record.Agent, as: AgentProj + + test "org agent's disposition skips the preamble" do + record = %Record{ + id: "agents/scout", + title: "Scout", + format: :org, + class: :agent, + meta: %{"capabilities" => "records.read"}, + body: """ + #+TITLE: Scout + :PROPERTIES: + :ID: agents/scout + :CLASS: agent + :CAPABILITIES: records.read + :END: + + You are a careful researcher. + """ + } + + config = AgentProj.from(record) + assert config.disposition == "You are a careful researcher." + end + + test "markdown agent uses body verbatim (already post-frontmatter)" do + record = %Record{ + id: "agents/scout", + title: "Scout", + format: :markdown, + class: :agent, + meta: %{"capabilities" => "records.read"}, + body: "You are a careful researcher." + } + + assert AgentProj.from(record).disposition == "You are a careful researcher." + end + end +end diff --git a/test/egghead/record/org_round_trip_test.exs b/test/egghead/record/org_round_trip_test.exs new file mode 100644 index 0000000..67f2231 --- /dev/null +++ b/test/egghead/record/org_round_trip_test.exs @@ -0,0 +1,250 @@ +defmodule Egghead.Record.OrgRoundTripTest do + @moduledoc """ + End-to-end round-trip tests for org-mode records through the RecordStore. + + These exercise the full path: create → write to disk → reparse → update → + re-read. The invariant being tested is **byte-stability of unrelated + content**: editing one field of an org record must not perturb anything + else on disk. + """ + + use ExUnit.Case, async: false + + alias Egghead.Index + alias Egghead.RecordStore + + setup context do + dir = + Path.join( + System.tmp_dir!(), + "egghead_org_rt_#{context.test |> to_string() |> :erlang.phash2()}" + ) + + File.rm_rf!(dir) + File.mkdir_p!(dir) + + suffix = :erlang.unique_integer([:positive]) + idx_name = :"index_#{suffix}" + store_name = :"store_#{suffix}" + + {:ok, _} = Index.start_link(db_path: ":memory:", name: idx_name) + + {:ok, _} = + RecordStore.start_link( + records_dir: dir, + name: store_name, + watch: false, + index: idx_name + ) + + on_exit(fn -> File.rm_rf!(dir) end) + + %{dir: dir, store: store_name} + end + + describe "create_record with format: :org" do + test "writes a .org file", %{dir: dir, store: store} do + assert {:ok, record} = + RecordStore.create_record(store, %{ + id: "rec_1", + title: "Hello", + format: :org, + body: "Some content." + }) + + assert record.format == :org + assert File.exists?(Path.join(dir, "rec_1.org")) + refute File.exists?(Path.join(dir, "rec_1.md")) + end + + test "accepts \"org\" string format", %{dir: dir, store: store} do + assert {:ok, _} = + RecordStore.create_record(store, %{ + "id" => "rec_2", + "title" => "T", + "format" => "org" + }) + + assert File.exists?(Path.join(dir, "rec_2.org")) + end + + test "default is markdown when no format given", %{dir: dir, store: store} do + assert {:ok, _} = RecordStore.create_record(store, %{id: "rec_3", title: "T"}) + assert File.exists?(Path.join(dir, "rec_3.md")) + end + + test "the written file parses back into the same record", %{store: store} do + attrs = %{ + id: "rec_rt", + title: "Round Trip", + author: "mark", + tags: ["a", "b"], + links: ["rec_other"], + class: "durable", + format: :org, + body: "Body content." + } + + assert {:ok, created} = RecordStore.create_record(store, attrs) + assert {:ok, fetched} = RecordStore.get_record(store, "rec_rt") + + assert fetched.id == created.id + assert fetched.title == "Round Trip" + assert fetched.author == "mark" + assert fetched.tags == ["a", "b"] + assert fetched.links == ["rec_other"] + assert fetched.class == :durable + assert fetched.format == :org + end + end + + describe "update_record on org records preserves format and unrelated bytes" do + test "body-only update keeps the file as .org", %{dir: dir, store: store} do + {:ok, _} = + RecordStore.create_record(store, %{ + id: "rec_body", + title: "T", + format: :org, + body: "Old body." + }) + + assert {:ok, _} = RecordStore.update_record(store, "rec_body", %{body: "New body."}) + + content = File.read!(Path.join(dir, "rec_body.org")) + assert content =~ "New body." + refute content =~ "Old body." + end + + test "title update splices in place; body and other fields untouched on disk", + %{dir: dir, store: store} do + {:ok, _} = + RecordStore.create_record(store, %{ + id: "rec_title", + title: "Original", + author: "mark", + tags: ["x"], + format: :org, + body: "Don't touch this body." + }) + + original = File.read!(Path.join(dir, "rec_title.org")) + + assert {:ok, updated} = + RecordStore.update_record(store, "rec_title", %{title: "Renamed"}) + + after_content = File.read!(Path.join(dir, "rec_title.org")) + + # Title swapped. + assert after_content =~ "#+TITLE: Renamed" + refute after_content =~ "#+TITLE: Original" + + # Author + tags + body line-for-line preserved. + assert after_content =~ "#+AUTHOR: mark" + assert after_content =~ "#+FILETAGS: :x:" + assert after_content =~ "Don't touch this body." + + # Diff is exactly one line of difference (the title line). + diff_count = line_diff_count(original, after_content) + assert diff_count == 1 + assert updated.title == "Renamed" + end + + test "tag update preserves user's existing keyword casing", %{dir: dir, store: store} do + raw = """ + #+title: My Doc + #+filetags: :old: + + Body. + """ + + path = Path.join(dir, "rec_case.org") + File.write!(path, raw) + Index.rebuild(store_index(store), dir) + + {:ok, _} = RecordStore.update_record(store, "rec_case", %{tags: ["new"]}) + + after_content = File.read!(path) + # Lower-case keywords stay lower-case; only the value changes. + assert after_content =~ "#+title: My Doc" + assert after_content =~ "#+filetags: :new:" + end + + test "body-only update with the full file content is byte-stable", + %{dir: dir, store: store} do + {:ok, _} = + RecordStore.create_record(store, %{ + id: "rec_stable", + title: "T", + format: :org, + body: "Some content." + }) + + path = Path.join(dir, "rec_stable.org") + original = File.read!(path) + + # For org records, `body` is the full file content. A body-only update + # with the same content should leave the file byte-identical. + {:ok, _} = + RecordStore.update_record(store, "rec_stable", %{ + body: String.trim_trailing(original) + }) + + after_content = File.read!(path) + assert after_content == original + end + end + + describe "create_record reads stored format on get" do + test "format field flows through index and hydrate", %{store: store} do + {:ok, _} = + RecordStore.create_record(store, %{id: "rec_fmt", title: "T", format: :org}) + + {:ok, fetched} = RecordStore.get_record(store, "rec_fmt") + assert fetched.format == :org + + {:ok, _} = + RecordStore.create_record(store, %{id: "rec_md", title: "T", format: :markdown}) + + {:ok, fetched_md} = RecordStore.get_record(store, "rec_md") + assert fetched_md.format == :markdown + end + end + + describe "default_format application env" do + setup do + original = Application.get_env(:egghead, :default_format, :markdown) + on_exit(fn -> Application.put_env(:egghead, :default_format, original) end) + :ok + end + + test "create_record without format honors :default_format = :org", %{ + dir: dir, + store: store + } do + Application.put_env(:egghead, :default_format, :org) + + {:ok, _} = RecordStore.create_record(store, %{id: "rec_def", title: "T"}) + + assert File.exists?(Path.join(dir, "rec_def.org")) + end + end + + # --- Helpers --- + + defp line_diff_count(a, b) do + a_lines = String.split(a, "\n") + b_lines = String.split(b, "\n") + + a_lines + |> Enum.zip(b_lines) + |> Enum.count(fn {x, y} -> x != y end) + |> Kernel.+(abs(length(a_lines) - length(b_lines))) + end + + # The store_name we register the genserver under doesn't directly map to the + # index name. Pull the store's state to get its index ref. + defp store_index(store) do + state = :sys.get_state(Process.whereis(store)) + state.index + end +end diff --git a/test/egghead/record/org_writer_test.exs b/test/egghead/record/org_writer_test.exs new file mode 100644 index 0000000..8daff61 --- /dev/null +++ b/test/egghead/record/org_writer_test.exs @@ -0,0 +1,253 @@ +defmodule Egghead.Record.OrgWriterTest do + use ExUnit.Case, async: true + + alias Egghead.Record.OrgWriter + alias Egghead.Record.Parser + + describe "new/1 — render skeleton from attrs" do + test "emits a #+TITLE keyword and properties drawer" do + content = + OrgWriter.new(%{ + "id" => "rec_001", + "title" => "Hello", + "author" => "mark", + "tags" => ["alpha", "beta"], + "links" => ["rec_002"], + "class" => "durable", + "body" => "First paragraph." + }) + + assert content =~ "#+TITLE: Hello" + assert content =~ "#+AUTHOR: mark" + assert content =~ "#+FILETAGS: :alpha:beta:" + assert content =~ ":ID: rec_001" + assert content =~ ":LINKS: rec_002" + assert content =~ ":CLASS: durable" + assert content =~ "First paragraph." + assert String.ends_with?(content, "\n") + end + + test "round-trips through the parser" do + attrs = %{ + "id" => "rec_round", + "title" => "Round Trip", + "author" => "mark", + "tags" => ["x", "y"], + "links" => ["rec_a"], + "class" => "durable", + "body" => "* A heading\n\nSome body." + } + + content = OrgWriter.new(attrs) + + assert {:ok, record} = Parser.parse(content) + assert record.id == "rec_round" + assert record.title == "Round Trip" + assert record.author == "mark" + assert record.tags == ["x", "y"] + assert record.links == ["rec_a"] + assert record.class == :durable + assert record.format == :org + assert record.body == String.trim_trailing(content) + end + + test "extra meta fields go into the drawer" do + content = + OrgWriter.new(%{ + "id" => "rec_extra", + "title" => "Extras", + "description" => "A thing", + "purpose" => "test" + }) + + assert content =~ ":DESCRIPTION: A thing" + assert content =~ ":PURPOSE: test" + end + + test "no body produces a preamble-only file" do + content = OrgWriter.new(%{"id" => "rec_empty", "title" => "Empty"}) + assert String.ends_with?(content, ":END:\n") + refute content =~ "\n\n\n" + end + end + + describe "splice_metadata/2 — preserves byte fidelity" do + test "replaces an existing #+TITLE in place, preserving keyword casing" do + original = """ + #+title: Original Title + #+AUTHOR: mark + + Body text. + """ + + updated = OrgWriter.splice_metadata(original, %{"title" => "New Title"}) + + # Keyword casing preserved. + assert updated =~ ~r/^#\+title: New Title$/m + # Author untouched. + assert updated =~ "#+AUTHOR: mark" + # Body untouched. + assert updated =~ "Body text." + end + + test "inserts a #+TITLE when absent" do + original = """ + #+AUTHOR: mark + + Body text. + """ + + updated = OrgWriter.splice_metadata(original, %{"title" => "Inserted"}) + assert updated =~ "#+TITLE: Inserted" + assert updated =~ "#+AUTHOR: mark" + assert updated =~ "Body text." + end + + test "replaces #+FILETAGS preserving form" do + original = """ + #+TITLE: T + #+FILETAGS: :old:tags: + + Body. + """ + + updated = OrgWriter.splice_metadata(original, %{"tags" => ["new", "tags"]}) + assert updated =~ "#+FILETAGS: :new:tags:" + refute updated =~ ":old:" + end + + test "falls back to existing #+TAGS line if user used that form" do + original = """ + #+TITLE: T + #+TAGS: foo bar + + Body. + """ + + updated = OrgWriter.splice_metadata(original, %{"tags" => ["baz"]}) + assert updated =~ "#+TAGS: :baz:" + refute updated =~ "#+TAGS: foo" + end + + test "edits properties drawer entries in place" do + original = """ + #+TITLE: T + :PROPERTIES: + :ID: rec_old + :CUSTOM: keep-me + :CLASS: durable + :END: + + Body. + """ + + updated = + OrgWriter.splice_metadata(original, %{ + "id" => "rec_new", + "class" => "inbox" + }) + + assert updated =~ ":ID: rec_new" + assert updated =~ ":CLASS: inbox" + # Custom drawer entry preserved. + assert updated =~ ":CUSTOM: keep-me" + end + + test "creates a properties drawer when missing" do + original = """ + #+TITLE: T + + Body. + """ + + updated = OrgWriter.splice_metadata(original, %{"id" => "rec_x"}) + assert updated =~ ":PROPERTIES:\n:ID: rec_x\n:END:" + assert updated =~ "Body." + end + + test "ignores nil values (no-op)" do + original = "#+TITLE: T\n\nBody.\n" + updated = OrgWriter.splice_metadata(original, %{"title" => nil}) + assert updated == original + end + + test ":remove deletes the keyword line" do + original = """ + #+TITLE: Old + #+AUTHOR: mark + + Body. + """ + + updated = OrgWriter.splice_metadata(original, %{"title" => :remove}) + refute updated =~ "#+TITLE:" + assert updated =~ "#+AUTHOR: mark" + end + + test "skips drawers attached to a headline (file-level only)" do + original = """ + #+TITLE: T + + * First Heading + :PROPERTIES: + :ID: heading-id + :END: + + Body under heading. + """ + + updated = OrgWriter.splice_metadata(original, %{"id" => "rec_file"}) + # The heading drawer's :ID stays the heading's. + assert updated =~ ":ID: heading-id" + # A file-level drawer was created at the top. + assert updated =~ ~r/:PROPERTIES:\s*\n:ID: rec_file\s*\n:END:/ + end + + test "is byte-stable when no attrs change" do + original = """ + #+TITLE: Stable + #+AUTHOR: mark + :PROPERTIES: + :ID: rec_stable + :CLASS: durable + :END: + + * Content + + With paragraphs. + """ + + assert OrgWriter.splice_metadata(original, %{}) == original + end + + test "byte-stability check across multiple metadata updates" do + original = """ + #+TITLE: Original + :PROPERTIES: + :ID: rec_x + :CLASS: durable + :END: + + Body content here. + """ + + step1 = OrgWriter.splice_metadata(original, %{"title" => "Step 1"}) + step2 = OrgWriter.splice_metadata(step1, %{"title" => "Original"}) + + # After round-tripping the title back, structural shape preserved. + assert step2 =~ "#+TITLE: Original" + assert step2 =~ ":ID: rec_x" + assert step2 =~ "Body content here." + end + end + + describe "format_filetags/1" do + test "wraps tags in colons" do + assert OrgWriter.format_filetags(["a", "b", "c"]) == ":a:b:c:" + end + + test "empty list returns empty string" do + assert OrgWriter.format_filetags([]) == "" + end + end +end diff --git a/test/egghead/tui/markdown_cache_test.exs b/test/egghead/tui/markdown_cache_test.exs index e210bf4..ddfda29 100644 --- a/test/egghead/tui/markdown_cache_test.exs +++ b/test/egghead/tui/markdown_cache_test.exs @@ -3,6 +3,7 @@ defmodule Egghead.TUI.MarkdownCacheTest do alias Egghead.OpenTUI.Markdown alias Egghead.TUI.MarkdownCache + alias Egghead.TUI.OrgRender setup do case GenServer.whereis(MarkdownCache) do @@ -61,4 +62,22 @@ defmodule Egghead.TUI.MarkdownCacheTest do # Restore for the rest of the suite. {:ok, _} = MarkdownCache.start_link([]) end + + test "format: :org routes to OrgRender" do + text = "* TODO Stuff\n\nSome /italics/." + + cached = MarkdownCache.render(text, 80, format: :org) + direct = OrgRender.render(text, 80) + + assert cached == direct + end + + test "same text with different formats is cached separately" do + text = "* heading" + + _ = MarkdownCache.render(text, 80, format: :markdown) + _ = MarkdownCache.render(text, 80, format: :org) + + assert MarkdownCache.size() == 2 + end end diff --git a/test/egghead/tui/org_render_test.exs b/test/egghead/tui/org_render_test.exs new file mode 100644 index 0000000..64d5583 --- /dev/null +++ b/test/egghead/tui/org_render_test.exs @@ -0,0 +1,200 @@ +defmodule Egghead.TUI.OrgRenderTest do + use ExUnit.Case, async: true + + alias Egghead.TUI.OrgRender + + defp flatten_text(rows) do + rows + |> Enum.map(fn row -> Enum.map_join(row, "", & &1.text) end) + |> Enum.join("\n") + end + + describe "headlines" do + test "preserves stars" do + rows = OrgRender.render("** Heading", 80) + text = flatten_text(rows) + assert text =~ "** Heading" + end + + test "renders TODO keyword and tags" do + rows = OrgRender.render("* TODO Buy milk :work:home:", 80) + text = flatten_text(rows) + assert text =~ "* TODO Buy milk" + assert text =~ ":work:home:" + end + + test "renders priority cookie" do + rows = OrgRender.render("* TODO [#A] Urgent", 80) + text = flatten_text(rows) + assert text =~ "[#A]" + end + end + + describe "keywords" do + test "#+TITLE keyword stays visible" do + rows = OrgRender.render("#+TITLE: My Doc", 80) + text = flatten_text(rows) + assert text =~ "#+TITLE: My Doc" + end + end + + describe "property drawer" do + test "drawer markers and entries appear" do + rows = + OrgRender.render( + """ + :PROPERTIES: + :ID: rec_1 + :CLASS: durable + :END: + """, + 80 + ) + + text = flatten_text(rows) + assert text =~ ":PROPERTIES:" + assert text =~ ":ID: rec_1" + assert text =~ ":CLASS: durable" + assert text =~ ":END:" + end + end + + describe "source blocks" do + test "shows BEGIN_SRC / END_SRC markers around code" do + rows = + OrgRender.render( + """ + #+BEGIN_SRC elixir + IO.puts("hi") + #+END_SRC + """, + 80 + ) + + text = flatten_text(rows) + assert text =~ "#+BEGIN_SRC elixir" + assert text =~ "IO.puts" + assert text =~ "#+END_SRC" + end + end + + describe "links" do + test "wikilink span carries the target for navigation" do + rows = OrgRender.render("See [[other-record][the link]].", 80) + + span = + rows + |> List.flatten() + |> Enum.find(fn s -> s.link == {:wikilink, "other-record"} end) + + assert span != nil + assert span.text =~ "[[other-record]" + end + + test "external link is not tagged as wikilink" do + rows = OrgRender.render("Visit [[https://example.com][example]].", 80) + + assert Enum.any?(List.flatten(rows), fn s -> + s.text =~ "https://example.com" and s.link == nil + end) + end + end + + describe "timestamps" do + test "active timestamp keeps angle brackets" do + rows = OrgRender.render("Met at <2026-04-30 Thu 10:00>.", 80) + text = flatten_text(rows) + assert text =~ "<2026-04-30 Thu 10:00>" + end + + test "inactive timestamp keeps square brackets" do + rows = OrgRender.render("Logged [2026-04-30].", 80) + text = flatten_text(rows) + assert text =~ "[2026-04-30]" + end + end + + describe "lists" do + test "renders bullets and checkboxes" do + rows = + OrgRender.render( + """ + - [ ] todo + - [X] done + - regular bullet + """, + 80 + ) + + text = flatten_text(rows) + assert text =~ "☐" + assert text =~ "☑" + end + end + + describe "inline markup" do + test "preserves the org markers" do + rows = OrgRender.render("Some *bold* and /italic/ and ~code~ words.", 80) + text = flatten_text(rows) + assert text =~ "*bold*" + assert text =~ "/italic/" + assert text =~ "~code~" + end + end + + describe "fallback safety" do + test "non-binary input returns empty rows but doesn't crash" do + assert OrgRender.render("", 80) == [] + end + end + + describe "paragraphs across multiple source lines" do + test "joins lines with a single space (no concatenation)" do + org = """ + You are Borges. Your home is the archive — not as warehouse but as + instrument. Records are not where knowledge is *stored*. + """ + + rows = OrgRender.render(org, 200) + text = flatten_text(rows) + assert text =~ "but as instrument" + refute text =~ "asinstrument" + end + + test "wraps a long paragraph at the requested width" do + org = + String.duplicate("hello ", 30) + |> String.trim_trailing() + |> Kernel.<>(".") + + rows = OrgRender.render(org, 40) + + # No row exceeds the width. + max_row_width = + rows + |> Enum.map(fn row -> row |> Enum.map(&String.length(&1.text)) |> Enum.sum() end) + |> Enum.max(fn -> 0 end) + + assert max_row_width <= 40 + # And we have *more than one* row — wrap actually happened. + content_rows = Enum.reject(rows, &(&1 == [])) + assert length(content_rows) > 1 + end + + test "inline markup survives across source-wrapped lines" do + org = """ + Some text *that is bold* spans + two source lines but parses fine. + """ + + rows = OrgRender.render(org, 200) + + bold_span = + rows + |> List.flatten() + |> Enum.find(fn s -> s.text == "*that is bold*" end) + + assert bold_span != nil + end + end +end diff --git a/test/egghead/web/org_html_test.exs b/test/egghead/web/org_html_test.exs new file mode 100644 index 0000000..cb8e883 --- /dev/null +++ b/test/egghead/web/org_html_test.exs @@ -0,0 +1,234 @@ +defmodule Egghead.Web.OrgHTMLTest do + use ExUnit.Case, async: true + + alias Egghead.Web.OrgHTML + + describe "headlines" do + test "renders headline stars and text with org-headline class" do + html = OrgHTML.render("* My Heading") + assert html =~ ~s|

    | + assert html =~ ~s|*| + assert html =~ ~s|My Heading| + end + + test "level controls heading tag and stars" do + html = OrgHTML.render("*** Third") + assert html =~ ~s|

    | + assert html =~ ~s|***| + end + + test "TODO keyword renders as a styled badge" do + html = OrgHTML.render("* TODO Buy milk") + assert html =~ ~s|TODO| + assert html =~ ~s|Buy milk| + end + + test "DONE keyword has its own class" do + html = OrgHTML.render("* DONE Wrote it") + assert html =~ ~s|DONE| + end + + test "priority cookie renders" do + html = OrgHTML.render("* TODO [#A] Urgent") + assert html =~ ~s|[#A]| + end + + test "tags render as chips" do + html = OrgHTML.render("* Heading :work:urgent:") + assert html =~ ~s|work| + assert html =~ ~s|urgent| + end + end + + describe "keywords" do + test "#+TITLE renders visibly" do + html = OrgHTML.render("#+TITLE: My Doc") + + assert html =~ + ~s|
    #+TITLE: My Doc
    | + end + + test "arbitrary #+KEY: keywords render with their key in data attr" do + html = OrgHTML.render("#+OPTIONS: toc:nil num:nil") + assert html =~ ~s|data-key="OPTIONS"| + assert html =~ "toc:nil num:nil" + end + end + + describe "property drawer" do + test "renders as a visible block with drawer markers" do + html = + OrgHTML.render(""" + :PROPERTIES: + :ID: rec_1 + :CLASS: durable + :END: + """) + + assert html =~ ~s|
    | + assert html =~ ~s|
    :PROPERTIES:
    | + assert html =~ ~s|
    :END:
    | + assert html =~ ~s|:ID:| + assert html =~ ~s|rec_1| + end + end + + describe "source blocks" do + test "frames code with #+BEGIN_SRC / #+END_SRC markers" do + html = + OrgHTML.render(""" + #+BEGIN_SRC elixir + IO.puts("hi") + #+END_SRC + """) + + assert html =~ ~s|
    | + assert html =~ ~s|
    #+BEGIN_SRC elixir
    | + assert html =~ ~s|
    #+END_SRC
    | + assert html =~ ~s|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|