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|