diff --git a/AGENTS.md b/AGENTS.md index 4119c31a6..d3e631480 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,13 +232,14 @@ Per-origin choices are persisted in cookies, so a user can override the automati ## Annotate Flow ``` -User runs /plannotator-annotate +User runs /plannotator-annotate ↓ Claude Code: plannotator annotate subcommand runs OpenCode/Pi: event handler intercepts command ↓ Input type detected: .md/.mdx → file read from disk + .adoc/.asciidoc → file read from disk, parsed natively by the AsciiDoc block parser .html/.htm → file read, rendered as raw HTML by default (or converted to markdown with --markdown) https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina) folder/ → file browser opened, files converted on demand diff --git a/README.md b/README.md index 54125b512..2b4375547 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Review local changes or remote PRs. Comment on diffs, suggest code. Your comment ``` /plannotator-annotate README.md # Local markdown file +/plannotator-annotate docs/guide.adoc # Local AsciiDoc file (parsed natively) /plannotator-annotate src/ # Browse and annotate files in a folder /plannotator-annotate https://docs.rs/… # Fetch and annotate any URL /plannotator-annotate report.html --render-html # Render HTML as-is instead of converting diff --git a/apps/hook/README.md b/apps/hook/README.md index 6f31b227b..3de3a082d 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -111,7 +111,7 @@ Plannotator's slash commands are installed as Claude Code skills in `~/.claude/s | Command | Description | |---------|-------------| | `/plannotator-review [--git]` | Open code review UI for current changes or a GitHub PR; `--git` forces Git in JJ workspaces | -| `/plannotator-annotate ` | Annotate a file, URL, or folder | +| `/plannotator-annotate ` | Annotate a file, URL, or folder | | `/plannotator-last` | Annotate the agent's last message | ## Obsidian Integration diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 2c8a8035c..d395fd0eb 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -26,7 +26,7 @@ describe("CLI top-level help", () => { expect(output).toContain("plannotator --version, -v"); expect(output).toContain("plannotator [--browser ]"); expect(output).toContain("plannotator review [--git] [PR_URL]"); - expect(output).toContain("plannotator annotate "); + expect(output).toContain("plannotator annotate "); expect(output).toContain("[--markdown] [--no-jina]"); expect(output).toContain("plannotator annotate-last [--stdin]"); expect(output).toContain("plannotator setup-goal "); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 49591fda2..fcedce04a 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -33,7 +33,7 @@ export function formatTopLevelHelp(): string { " plannotator --version, -v", " plannotator [--browser ]", " plannotator review [--git] [PR_URL]", - " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]", + " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]", " plannotator annotate-last [--stdin] [--gate] [--json] [--hook]", " plannotator setup-goal [--json]", " plannotator last", @@ -74,7 +74,7 @@ const SUBCOMMAND_HELP: Record = { ].join("\n"), annotate: [ "Usage:", - " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]", + " plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]", "", "Open a markdown/text/HTML file, a URL, or a folder of documents in the annotation UI.", "", @@ -165,7 +165,7 @@ export function formatInteractiveNoArgClarification(): string { "", "For interactive use, try:", " plannotator review", - " plannotator annotate ", + " plannotator annotate ", " plannotator setup-goal interview bundle.json --json", " plannotator last", " plannotator archive", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 53326ad78..f9cc511f3 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -97,6 +97,7 @@ import { createWorktreePool, type WorktreePool, type PoolEntry } from "@plannota import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { BROWSABLE_DOC_EXTENSIONS, isAsciidocPath } from "@plannotator/shared/document-format"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -894,7 +895,7 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]"); + console.error("Usage: plannotator annotate [--markdown] [--no-jina] [--gate] [--json] [--hook]"); process.exit(1); } @@ -947,9 +948,9 @@ if (args[0] === "sessions") { if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); - // Folder annotation mode (markdown/plain text + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|txt|html?)$/i)) { - console.error(`No markdown, text, or HTML files found in ${resolvedArg}`); + // Folder annotation mode (markdown/plain text/AsciiDoc + HTML files) + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, BROWSABLE_DOC_EXTENSIONS)) { + console.error(`No markdown, text, AsciiDoc, or HTML files found in ${resolvedArg}`); process.exit(1); } folderPath = resolvedArg; @@ -1003,7 +1004,7 @@ if (args[0] === "sessions") { const ext = path.extname(resolvedPath).toLowerCase(); console.error( `File type not supported: ${ext}\n` + - `Only .md, .mdx, .txt, .html, .htm files are supported.\n` + + `Only .md, .mdx, .txt, .adoc, .asciidoc, .html, .htm files are supported.\n` + `For code review, use: plannotator review [file]` ); } else { @@ -1050,7 +1051,9 @@ if (args[0] === "sessions") { pasteApiUrl, }).catch(() => {}); } else if (markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only", { + docFormat: !isUrl && isAsciidocPath(absolutePath) ? "asciidoc" : "markdown", + }).catch(() => {}); } } }, diff --git a/apps/marketing/src/content/docs/commands/annotate.md b/apps/marketing/src/content/docs/commands/annotate.md index 497b9cc64..20ba1887f 100644 --- a/apps/marketing/src/content/docs/commands/annotate.md +++ b/apps/marketing/src/content/docs/commands/annotate.md @@ -1,6 +1,6 @@ --- title: "Annotate" -description: "The /plannotator-annotate slash command for annotating markdown files, HTML files, URLs, and folders." +description: "The /plannotator-annotate slash command for annotating markdown files, AsciiDoc files, HTML files, URLs, and folders." sidebar: order: 12 section: "Commands" @@ -13,9 +13,10 @@ The `/plannotator-annotate` command opens files, URLs, or folders in the Plannot | Input | Command | What happens | |-------|---------|--------------| | Markdown file | `plannotator annotate README.md` | Opens the file directly | +| AsciiDoc file | `plannotator annotate docs/guide.adoc` | Opens the file directly, parsed natively | | HTML file | `plannotator annotate docs/guide.html` | Renders the HTML directly | | URL | `plannotator annotate https://docs.stripe.com/api` | Fetches the page, converts to markdown, then opens | -| Folder | `plannotator annotate ./docs/` | Opens a file browser showing all `.md`, `.mdx`, `.html`, and `.htm` files | +| Folder | `plannotator annotate ./docs/` | Opens a file browser showing all `.md`, `.mdx`, `.adoc`, `.asciidoc`, `.html`, and `.htm` files | ### Slash command (inside an agent session) @@ -40,7 +41,7 @@ Starts a local server, opens the browser, and blocks until you submit. Formatted ## Folders -When you pass a folder, Plannotator opens a file browser showing all markdown and HTML files in the directory tree. Click any file to open it in the annotation UI. This is useful for annotating a set of specs, documentation, or your Obsidian vault. +When you pass a folder, Plannotator opens a file browser showing all markdown, AsciiDoc, and HTML files in the directory tree. Click any file to open it in the annotation UI. This is useful for annotating a set of specs, documentation, or your Obsidian vault. Build output directories like `_site/`, `public/`, `.docusaurus/`, and `node_modules/` are automatically excluded from the file browser. @@ -80,6 +81,10 @@ Three ways to disable Jina Reader, in priority order: If none of these are set, Jina is enabled by default. +## AsciiDoc files + +Local `.adoc` and `.asciidoc` files are parsed natively — sections, listings (`[source,lang]` blocks get syntax highlighting), admonitions (`NOTE:`, `[WARNING]`, …), tables, quotes, and lists map onto the same annotatable blocks as markdown, and inline AsciiDoc formatting is converted for display. The file itself is never converted, so direct edits and source-save write the original AsciiDoc back to disk, and feedback line numbers refer to the real source lines. + ## HTML files Local `.html` and `.htm` files are read from disk and rendered as HTML by default. If you want Plannotator to convert the file to markdown first, pass `--markdown`. diff --git a/apps/opencode-plugin/cli-bridge.ts b/apps/opencode-plugin/cli-bridge.ts index 00dbc8316..ad4b2ee6a 100644 --- a/apps/opencode-plugin/cli-bridge.ts +++ b/apps/opencode-plugin/cli-bridge.ts @@ -493,7 +493,7 @@ export async function handleCliCommand(input: { if (input.command === "plannotator-annotate") { const parsed = parseAnnotateArgs(input.rawArgs); if (!parsed.filePath) { - log(input.client, "error", "Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]"); + log(input.client, "error", "Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]"); return; } diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 3ca36cd63..e9ba1a6a2 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -22,6 +22,7 @@ import { getAnnotateFileFeedbackPrompt, } from "@plannotator/shared/prompts"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { BROWSABLE_DOC_EXTENSIONS } from "@plannotator/shared/document-format"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; @@ -211,7 +212,7 @@ export async function handleAnnotateCommand( const { filePath, rawFilePath, gate, renderMarkdown: renderMarkdownFlag, noJina } = parseAnnotateArgs(rawArgs); if (!filePath) { - client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]" }); + client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]" }); return; } @@ -252,8 +253,8 @@ export async function handleAnnotateCommand( } if (isFolder) { - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|txt|html?)$/i)) { - client.app.log({ level: "error", message: `No markdown, text, or HTML files found in ${resolvedArg}` }); + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, BROWSABLE_DOC_EXTENSIONS)) { + client.app.log({ level: "error", message: `No markdown, text, AsciiDoc, or HTML files found in ${resolvedArg}` }); return; } folderPath = resolvedArg; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 36dbcdef0..1e286ac3f 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -32,6 +32,7 @@ import { parseChecklist, } from "./generated/checklist.js"; import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js"; +import { ANNOTATABLE_DOC_EXTENSIONS, BROWSABLE_DOC_EXTENSIONS } from "./generated/document-format.js"; import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js"; import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js"; @@ -502,7 +503,7 @@ export default function plannotator(pi: ExtensionAPI): void { // (scoped-package-style names). const { filePath, rawFilePath, gate, renderMarkdown: renderMarkdownFlag, noJina } = parseAnnotateArgs(args ?? ""); if (!filePath) { - ctx.ui.notify("Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]", "error"); + ctx.ui.notify("Usage: /plannotator-annotate [--markdown] [--no-jina] [--gate] [--json]", "error"); return; } if (!hasPlanBrowserHtml()) { @@ -562,8 +563,8 @@ export default function plannotator(pi: ExtensionAPI): void { } if (isFolder) { - if (!hasMarkdownFiles(absolutePath, FILE_BROWSER_EXCLUDED, /\.(mdx?|txt|html?)$/i)) { - ctx.ui.notify(`No markdown, text, or HTML files found in ${absolutePath}`, "error"); + if (!hasMarkdownFiles(absolutePath, FILE_BROWSER_EXCLUDED, BROWSABLE_DOC_EXTENSIONS)) { + ctx.ui.notify(`No markdown, text, AsciiDoc, or HTML files found in ${absolutePath}`, "error"); return; } markdown = ""; @@ -583,8 +584,8 @@ export default function plannotator(pi: ExtensionAPI): void { sourceInfo = basename(absolutePath); ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info"); } else { - if (!/\.(mdx?|txt)$/i.test(absolutePath)) { - ctx.ui.notify("Only .md, .mdx, .txt, .html, .htm files are supported.", "error"); + if (!ANNOTATABLE_DOC_EXTENSIONS.test(absolutePath)) { + ctx.ui.notify("Only .md, .mdx, .txt, .adoc, .asciidoc, .html, .htm files are supported.", "error"); return; } markdown = readFileSync(absolutePath, "utf-8"); diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 90a8e8fcf..77a19bc9b 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -40,6 +40,7 @@ import { } from "../generated/resolve-file.js"; import { parseCodePath } from "../generated/code-file.js"; import { htmlToMarkdown } from "../generated/html-to-markdown.js"; +import { BROWSABLE_DOC_EXTENSIONS } from "../generated/document-format.js"; import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "../generated/source-save.js"; import { createSourceSaveCapability, @@ -213,7 +214,7 @@ function jsonDoc( } /** Recursively walk a directory collecting files by extension, skipping ignored dirs. */ -const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; +const FILE_BROWSER_EXTENSIONS = BROWSABLE_DOC_EXTENSIONS; function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = FILE_BROWSER_EXTENSIONS): void { let entries: Dirent[]; @@ -261,7 +262,7 @@ export async function handleDocRequest(res: Res, url: URL, options: HandleDocOpt if ( resolvedBase && !isAbsoluteUserPath(requestedPath) && - /\.(mdx?|txt|html?)$/i.test(requestedPath) + BROWSABLE_DOC_EXTENSIONS.test(requestedPath) ) { const fromBase = resolveUserPath(requestedPath, resolvedBase); if (!isWithinAllowedRoots(fromBase, allowedRoots)) { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 680c35fbf..343b17639 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -8,7 +8,7 @@ rm -rf generated mkdir -p generated generated/ai/providers # Modules that MOVED to @plannotator/core — vendor the real impl from core. -for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps; do +for f in feedback-templates project favicon code-file external-annotation agent-jobs agent-terminal source-save open-in-apps document-format; do src="../../packages/core/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done diff --git a/apps/skills/claude/plannotator-annotate/SKILL.md b/apps/skills/claude/plannotator-annotate/SKILL.md index 5dc7e3730..2630015bc 100644 --- a/apps/skills/claude/plannotator-annotate/SKILL.md +++ b/apps/skills/claude/plannotator-annotate/SKILL.md @@ -1,6 +1,6 @@ --- name: plannotator-annotate -description: Open Plannotator's annotation UI for a markdown file, HTML file, URL, or folder and then respond to the returned annotations. +description: Open Plannotator's annotation UI for a markdown file, AsciiDoc file, HTML file, URL, or folder and then respond to the returned annotations. allowed-tools: Bash(plannotator:*) disable-model-invocation: true --- diff --git a/apps/skills/core/plannotator-annotate/SKILL.md b/apps/skills/core/plannotator-annotate/SKILL.md index c08e958e1..ef3ced7d8 100644 --- a/apps/skills/core/plannotator-annotate/SKILL.md +++ b/apps/skills/core/plannotator-annotate/SKILL.md @@ -1,6 +1,6 @@ --- name: plannotator-annotate -description: Open Plannotator's annotation UI for a markdown file, HTML file, URL, or folder and then respond to the returned annotations. +description: Open Plannotator's annotation UI for a markdown file, AsciiDoc file, HTML file, URL, or folder and then respond to the returned annotations. disable-model-invocation: true --- diff --git a/bun.lock b/bun.lock index f1f47d245..e5fc4d978 100644 --- a/bun.lock +++ b/bun.lock @@ -91,6 +91,7 @@ "@pierre/diffs": "1.2.8", "@plannotator/webtui": "0.1.0", "chokidar": "^5.0.0", + "diff": "^8.0.4", "parse5": "^7.3.0", "turndown": "^7.2.4", }, @@ -167,6 +168,16 @@ "packages/ai": { "name": "@plannotator/ai", "version": "0.0.1", + "dependencies": { + "@plannotator/core": "workspace:*", + }, + }, + "packages/core": { + "name": "@plannotator/core", + "version": "0.22.0", + "devDependencies": { + "typescript": "~5.8.2", + }, }, "packages/editor": { "name": "@plannotator/editor", @@ -234,13 +245,14 @@ "version": "0.0.1", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", + "@plannotator/core": "workspace:*", "parse5": "^7.3.0", "turndown": "^7.2.4", }, }, "packages/ui": { "name": "@plannotator/ui", - "version": "0.0.1", + "version": "0.22.0", "dependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", @@ -259,10 +271,9 @@ "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.3", "@pierre/diffs": "1.2.8", - "@plannotator/ai": "workspace:*", "@plannotator/atomic-editor": "^0.5.0", + "@plannotator/core": "workspace:*", "@plannotator/markdown-editor": "^0.2.0", - "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -274,7 +285,8 @@ "@viz-js/viz": "^3.25.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "diff": "^8.0.3", + "diff": "^8.0.4", + "dompurify": "^3.3.3", "highlight.js": "^11.11.1", "katex": "^0.16.47", "lucide-react": "^1.14.0", @@ -282,19 +294,27 @@ "mermaid": "^11.12.2", "motion": "^12.38.0", "perfect-freehand": "^1.2.2", - "react": "^19.2.3", - "react-dom": "^19.2.3", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.1.18", - "tailwindcss-animate": "^1.0.7", "unique-username-generator": "^1.5.1", }, "devDependencies": { "@happy-dom/global-registrator": "^20.10.1", + "@tailwindcss/vite": "^4.1.18", "@types/bun": "^1.2.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", "typescript": "~5.8.2", + "vite": "^6.2.0", + }, + "peerDependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", }, }, }, @@ -781,6 +801,8 @@ "@plannotator/atomic-editor": ["@plannotator/atomic-editor@0.5.0", "", { "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/markdown": "^1.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@codemirror/lang-cpp", "@codemirror/lang-css", "@codemirror/lang-go", "@codemirror/lang-html", "@codemirror/lang-java", "@codemirror/lang-javascript", "@codemirror/lang-json", "@codemirror/lang-php", "@codemirror/lang-python", "@codemirror/lang-rust", "@codemirror/lang-sql", "@codemirror/lang-xml", "@codemirror/lang-yaml", "@codemirror/legacy-modes"] }, "sha512-nJfa6CZ3S0Ywg++HxJLZ9AqPrruhrurVAisFFvCIQmyUnyEt270CfNnVoVyUZBGYHPy47mtybtMZJaN9LXpnCg=="], + "@plannotator/core": ["@plannotator/core@workspace:packages/core"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], diff --git a/packages/core/document-format.ts b/packages/core/document-format.ts new file mode 100644 index 000000000..fb2557a59 --- /dev/null +++ b/packages/core/document-format.ts @@ -0,0 +1,25 @@ +/** + * Document format detection shared by the client and all server runtimes. + * + * The format of an annotatable document is derived from its file extension — + * there is no format field on the wire. Servers gate which files are + * annotatable/browsable; the client picks the block parser from the path. + */ + +export type DocumentFormat = "markdown" | "asciidoc"; + +export const ASCIIDOC_PATH_REGEX = /\.(adoc|asciidoc)$/i; + +export const isAsciidocPath = (path: string): boolean => + ASCIIDOC_PATH_REGEX.test(path.trim()); + +export const documentFormatForPath = ( + path?: string | null, +): DocumentFormat => (path && isAsciidocPath(path) ? "asciidoc" : "markdown"); + +/** Text documents parsed into annotatable blocks (resolve gate). */ +export const ANNOTATABLE_DOC_EXTENSIONS = /\.(mdx?|txt|adoc|asciidoc)$/i; + +/** Folder guard / file browser / `/api/doc` base gate — adds HTML, which is + * browsable but rendered raw rather than parsed to blocks. */ +export const BROWSABLE_DOC_EXTENSIONS = /\.(mdx?|txt|adoc|asciidoc|html?)$/i; diff --git a/packages/core/package.json b/packages/core/package.json index 039691993..99caabd5e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,7 @@ "./code-file": "./code-file.ts", "./compress": "./compress.ts", "./crypto": "./crypto.ts", + "./document-format": "./document-format.ts", "./external-annotation": "./external-annotation.ts", "./extract-code-paths": "./extract-code-paths.ts", "./favicon": "./favicon.ts", diff --git a/packages/core/source-save.ts b/packages/core/source-save.ts index 434f72183..c61c5a74f 100644 --- a/packages/core/source-save.ts +++ b/packages/core/source-save.ts @@ -1,4 +1,4 @@ -export type SourceSaveLanguage = "markdown" | "mdx" | "text"; +export type SourceSaveLanguage = "markdown" | "mdx" | "text" | "asciidoc"; export type SourceSaveDisabledReason = | "not-annotate-mode" @@ -93,7 +93,7 @@ export function hasSourceSaveConflictSnapshot(response: SourceSaveResponse): res ); } -export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt)$/i; +export const SOURCE_SAVE_FILE_REGEX = /\.(md|mdx|txt|adoc|asciidoc)$/i; export function isSourceSaveFilePath(filePath: string): boolean { return SOURCE_SAVE_FILE_REGEX.test(filePath); @@ -104,6 +104,7 @@ export function getSourceSaveLanguage(filePath: string): SourceSaveLanguage | nu if (lower.endsWith(".mdx")) return "mdx"; if (lower.endsWith(".md")) return "markdown"; if (lower.endsWith(".txt")) return "text"; + if (lower.endsWith(".adoc") || lower.endsWith(".asciidoc")) return "asciidoc"; return null; } diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index ad43bf33f..987df1180 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -3,6 +3,8 @@ import { toast, Toaster } from 'sonner'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { annotateFileFeedback, annotateMessageFeedback } from '@plannotator/shared/feedback-templates'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, exportMessageAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry, type MessageAnnotationEntry } from '@plannotator/ui/utils/parser'; +import { parseDocumentToBlocks } from '@plannotator/ui/utils/asciidocParser'; +import { documentFormatForPath, type DocumentFormat } from '@plannotator/shared/document-format'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { HtmlViewer } from '@plannotator/ui/components/html-viewer'; import { MarkdownEditor, type MarkdownEditorHandle } from '@plannotator/ui/components/MarkdownEditor'; @@ -189,6 +191,7 @@ const createEmptyMessageState = (message: PickerMessage): MessageAnnotationState renderAs: 'markdown', rawHtml: '', shareHtml: '', + docFormat: 'markdown', annotations: [], selectedAnnotationId: null, globalAttachments: [], @@ -216,6 +219,7 @@ const normalizeMessageState = ( renderAs: state.linkedDocSession.root.renderAs ?? 'markdown', rawHtml: state.linkedDocSession.root.rawHtml ?? '', shareHtml: state.linkedDocSession.root.shareHtml ?? '', + docFormat: state.linkedDocSession.root.docFormat ?? 'markdown', }, docs: new Map(state.linkedDocSession.docs), }, @@ -263,8 +267,17 @@ const App: React.FC = () => { const editableDocuments = useEditableDocuments(); const activeEditableDocument = editableDocuments.activeDocument; const displayedMarkdown = activeEditableDocument?.currentText ?? markdown; - const frontmatter = useMemo(() => extractFrontmatter(displayedMarkdown).frontmatter, [displayedMarkdown]); - const blocks = useMemo(() => parseMarkdownToBlocks(displayedMarkdown), [displayedMarkdown]); + // Block-parser format of the ACTIVE document (root plan, linked doc, or folder + // file). Derived from the file extension; .adoc parses with the native + // AsciiDoc parser. Declared here (above the memos that consume it) and + // snapshot/restored alongside renderAs by useLinkedDoc. + const [docFormat, setDocFormat] = useState('markdown'); + // AsciiDoc owns its own document header — frontmatter is a markdown concept. + const frontmatter = useMemo( + () => (docFormat === 'asciidoc' ? null : extractFrontmatter(displayedMarkdown).frontmatter), + [displayedMarkdown, docFormat], + ); + const blocks = useMemo(() => parseDocumentToBlocks(displayedMarkdown, docFormat), [displayedMarkdown, docFormat]); const [showExport, setShowExport] = useState(false); const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); @@ -752,6 +765,7 @@ const App: React.FC = () => { markdown, annotations, selectedAnnotationId, globalAttachments, setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, renderAs, rawHtml, shareHtml, setRenderAs, setRawHtml, setShareHtml, + docFormat, setDocFormat, viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted, onBeforeNavigate: snapshotActiveEditableDocument, onDocumentLoaded: handleLinkedDocumentLoaded, @@ -973,7 +987,7 @@ const App: React.FC = () => { for (const [filepath, doc] of state.linkedDocSession.docs) { linkedDocs.set(filepath, { ...doc, - blocks: doc.markdown ? parseMarkdownToBlocks(doc.markdown) : undefined, + blocks: doc.markdown ? parseDocumentToBlocks(doc.markdown, documentFormatForPath(filepath)) : undefined, }); } return { @@ -1308,7 +1322,7 @@ const App: React.FC = () => { const enriched: Map = new Map(docAnnotations); for (const [filepath, entry] of enriched) { if (entry.markdown) { - enriched.set(filepath, { ...entry, blocks: parseMarkdownToBlocks(entry.markdown) }); + enriched.set(filepath, { ...entry, blocks: parseDocumentToBlocks(entry.markdown, documentFormatForPath(filepath)) }); } } output += exportLinkedDocAnnotations(enriched); @@ -1381,6 +1395,8 @@ const App: React.FC = () => { setRawHtml, setShareHtml, setRenderAs, + docFormat, + setDocFormat, ); useEffect(() => { @@ -1524,7 +1540,7 @@ const App: React.FC = () => { // which isn't in state yet when the remap runs. const applyEditedDocument = useCallback((next: string, list?: Annotation[]): Annotation[] => { const sourceAnnotations = list ?? annotationsRef.current; - const newBlocks = parseMarkdownToBlocks(next); + const newBlocks = parseDocumentToBlocks(next, docFormat); const remapped = sourceAnnotations.map((a) => { if (a.diffContext || a.type === AnnotationType.GLOBAL_COMMENT || a.id.startsWith('ann-checkbox-')) return a; const blk = newBlocks.find((b) => b.content.includes(a.originalText)); @@ -1538,7 +1554,7 @@ const App: React.FC = () => { annotationsRef.current = remapped; setAnnotations(remapped); return remapped; - }, []); + }, [docFormat]); // The Viewer is remounted after every edit-mode exit (it was unmounted while // editing), so highlight DOM is rebuilt from scratch. Re-anchor via the same @@ -2268,6 +2284,10 @@ const App: React.FC = () => { } setSourceInfo(data.sourceInfo ?? undefined); setSourceConverted(!!data.sourceConverted); + // Pick the block parser from the source file's extension. Only single-file + // annotate carries a real document path — URLs, messages, folders (until a + // file is opened), archive, and plan mode all parse as markdown. + setDocFormat(documentFormatForPath(data.mode === 'annotate' && !data.sourceInfo?.startsWith('http') ? data.filePath : undefined)); if (data.filePath) { setImageBaseDir(data.mode === 'annotate-folder' ? data.filePath : data.filePath.replace(/\/[^/]+$/, '')); if (data.mode === 'annotate') { diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts index 389e05374..f04b42038 100644 --- a/packages/server/reference-handlers.test.ts +++ b/packages/server/reference-handlers.test.ts @@ -165,6 +165,36 @@ describe("handleDocExists", () => { expect(data.markdown).toBe("linked\n"); expect(data.sourceSave).toBeUndefined(); }); + + test("serves an .adoc document raw (no conversion) with source-save metadata", async () => { + const root = makeTempDir("plannotator-doc-root-"); + const adoc = "= Guide\n\nNOTE: native parse\n"; + const source = writeTempFile(root, "docs/guide.adoc", adoc); + + const res = await getDoc(source, { + rootPaths: [root], + sourceSaveFilePath: source, + }); + const data = await res.json() as { markdown?: string; rawHtml?: string; isConverted?: boolean; renderAs?: string; sourceSave?: { enabled: boolean } }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe(adoc); + expect(data.rawHtml).toBeUndefined(); + expect(data.isConverted).toBeFalsy(); + expect(data.renderAs).not.toBe("html"); + expect(data.sourceSave?.enabled).toBe(true); + }); + + test("resolves an .adoc document relative to a base directory", async () => { + const root = makeTempDir("plannotator-doc-root-"); + writeTempFile(root, "docs/linked.adoc", "== Linked\n"); + + const res = await getDoc("linked.adoc", { base: join(root, "docs"), rootPaths: [root] }); + const data = await res.json() as { markdown?: string; filepath?: string }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe("== Linked\n"); + }); }); describe("handleFileBrowserFiles", () => { @@ -219,4 +249,20 @@ describe("handleFileBrowserFiles", () => { expect(data.workspaceStatus.totals.files).toBe(0); expect(data.workspaceStatus.files).toEqual({}); }); + + test("lists .adoc and .asciidoc files alongside markdown", async () => { + const root = makeTempDir("plannotator-files-adoc-"); + writeTempFile(root, "docs/readme.md", "md\n"); + writeTempFile(root, "docs/guide.adoc", "= Guide\n"); + writeTempFile(root, "docs/legacy.asciidoc", "= Legacy\n"); + writeTempFile(root, "docs/ignored.bin", "binary\n"); + + const url = new URL("http://localhost/api/reference/files"); + url.searchParams.set("dirPath", join(root, "docs")); + const res = await handleFileBrowserFiles(new Request(url.toString())); + const data = await res.json() as { tree: VaultNode[] }; + + expect(res.status).toBe(200); + expect(flattenTree(data.tree).sort()).toEqual(["guide.adoc", "legacy.asciidoc", "readme.md"]); + }); }); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 53c4676d3..f95d08fd1 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -27,6 +27,7 @@ import { warmFileListCache, } from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; +import { BROWSABLE_DOC_EXTENSIONS } from "@plannotator/shared/document-format"; import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "@plannotator/shared/source-save"; import { createSourceSaveCapability, @@ -218,7 +219,7 @@ export async function handleDoc(req: Request, options: HandleDocOptions = {}): P if ( resolvedBase && !isAbsoluteUserPath(requestedPath) && - /\.(mdx?|txt|html?)$/i.test(requestedPath) + BROWSABLE_DOC_EXTENSIONS.test(requestedPath) ) { const fromBase = resolveUserPath(requestedPath, resolvedBase); if (!isWithinAllowedRoots(fromBase, allowedRoots)) { @@ -542,7 +543,7 @@ export async function handleObsidianDoc(req: Request): Promise { // --- File Browser --- -const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; +const FILE_BROWSER_EXTENSIONS = BROWSABLE_DOC_EXTENSIONS; function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath); diff --git a/packages/server/share-url.ts b/packages/server/share-url.ts index da67d6336..7818abb1b 100644 --- a/packages/server/share-url.ts +++ b/packages/server/share-url.ts @@ -13,6 +13,9 @@ const DEFAULT_PASTE_API = "https://plannotator-paste.plannotator.workers.dev"; export interface RemoteShareOptions { rawHtml?: string; + /** Source document format — 'asciidoc' adds the `f: 'adoc'` payload flag so + * the share portal parses with the AsciiDoc block parser. */ + docFormat?: "markdown" | "asciidoc"; pasteApiUrl?: string; fetchImpl?: typeof fetch; } @@ -39,7 +42,11 @@ export async function generateRemoteShareUrl( options.fetchImpl, ); } - const hash = await compress({ p: plan, a: [] }); + const hash = await compress({ + p: plan, + a: [], + ...(options.docFormat === "asciidoc" ? { f: "adoc" } : {}), + }); return `${base}/#${hash}`; } diff --git a/packages/shared/document-format.ts b/packages/shared/document-format.ts new file mode 100644 index 000000000..257b6714e --- /dev/null +++ b/packages/shared/document-format.ts @@ -0,0 +1 @@ +export * from '@plannotator/core/document-format'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 4e4af9853..d0a6a18a4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -30,6 +30,7 @@ "./reference-common": "./reference-common.ts", "./favicon": "./favicon.ts", "./code-file": "./code-file.ts", + "./document-format": "./document-format.ts", "./resolve-file": "./resolve-file.ts", "./annotate-reference-roots-node": "./annotate-reference-roots-node.ts", "./extract-code-paths": "./extract-code-paths.ts", diff --git a/packages/shared/resolve-file.test.ts b/packages/shared/resolve-file.test.ts index a9a9c98a2..f6c494eb9 100644 --- a/packages/shared/resolve-file.test.ts +++ b/packages/shared/resolve-file.test.ts @@ -2,7 +2,12 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { resolveCodeFile } from "./resolve-file"; +import { resolveCodeFile, resolveMarkdownFile, hasMarkdownFiles } from "./resolve-file"; +import { + BROWSABLE_DOC_EXTENSIONS, + documentFormatForPath, + isAsciidocPath, +} from "./document-format"; let root: string; @@ -12,11 +17,16 @@ beforeAll(() => { mkdirSync(join(root, "packages/review-editor"), { recursive: true }); mkdirSync(join(root, "packages/ui/components"), { recursive: true }); mkdirSync(join(root, "node_modules/junk"), { recursive: true }); + mkdirSync(join(root, "docs"), { recursive: true }); + mkdirSync(join(root, "adoc-only"), { recursive: true }); writeFileSync(join(root, "packages/editor/App.tsx"), "// editor"); writeFileSync(join(root, "packages/review-editor/App.tsx"), "// review"); writeFileSync(join(root, "packages/ui/components/Button.tsx"), "// btn"); writeFileSync(join(root, "packages/ui/index.ts"), "// idx"); writeFileSync(join(root, "node_modules/junk/App.tsx"), "// junk"); + writeFileSync(join(root, "docs/guide.adoc"), "= Guide"); + writeFileSync(join(root, "docs/legacy.asciidoc"), "= Legacy"); + writeFileSync(join(root, "adoc-only/notes.adoc"), "= Notes"); }); afterAll(() => { @@ -111,3 +121,54 @@ describe("resolveCodeFile", () => { } }); }); + +describe("resolveMarkdownFile with AsciiDoc", () => { + test("resolves an exact .adoc path", () => { + const r = resolveMarkdownFile("docs/guide.adoc", root); + expect(r.kind).toBe("found"); + if (r.kind === "found") { + expect(r.path).toBe(join(root, "docs/guide.adoc")); + } + }); + + test("resolves a bare .adoc filename via project walk", () => { + const r = resolveMarkdownFile("notes.adoc", root); + expect(r.kind).toBe("found"); + if (r.kind === "found") { + expect(r.path).toBe(join(root, "adoc-only/notes.adoc")); + } + }); + + test("resolves the long .asciidoc extension", () => { + const r = resolveMarkdownFile("legacy.asciidoc", root); + expect(r.kind).toBe("found"); + if (r.kind === "found") { + expect(r.path).toBe(join(root, "docs/legacy.asciidoc")); + } + }); +}); + +describe("hasMarkdownFiles with AsciiDoc", () => { + test("finds .adoc files with the browsable extension gate", () => { + expect(hasMarkdownFiles(join(root, "adoc-only"), undefined, BROWSABLE_DOC_EXTENSIONS)).toBe(true); + }); + + test("md-only default does not match .adoc", () => { + expect(hasMarkdownFiles(join(root, "adoc-only"))).toBe(false); + }); +}); + +describe("document-format helpers", () => { + test("documentFormatForPath maps extensions", () => { + expect(documentFormatForPath("/x/guide.adoc")).toBe("asciidoc"); + expect(documentFormatForPath("/x/guide.AsciiDoc")).toBe("asciidoc"); + expect(documentFormatForPath("/x/readme.md")).toBe("markdown"); + expect(documentFormatForPath(undefined)).toBe("markdown"); + expect(documentFormatForPath(null)).toBe("markdown"); + }); + + test("isAsciidocPath tolerates surrounding whitespace", () => { + expect(isAsciidocPath(" docs/guide.adoc ")).toBe(true); + expect(isAsciidocPath("docs/guide.adoc.bak")).toBe(false); + }); +}); diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index 43112279d..364818332 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -13,7 +13,9 @@ import { homedir } from "os"; import { isAbsolute, join, resolve, win32 } from "path"; import { existsSync, readdirSync, type Dirent } from "fs"; -const MARKDOWN_PATH_REGEX = /\.(mdx?|txt)$/i; +import { ANNOTATABLE_DOC_EXTENSIONS } from "./document-format"; + +const MARKDOWN_PATH_REGEX = ANNOTATABLE_DOC_EXTENSIONS; import { CODE_FILE_REGEX as CODE_FILE_BASENAME_REGEX } from "./code-file"; export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file"; @@ -222,7 +224,7 @@ function walkFiles( function walkMarkdownFiles(dir: string, root: string, results: string[], ignoredDirs: string[]): void { try { - walkFiles(dir, root, results, ignoredDirs, (name) => /\.(mdx?|txt)$/i.test(name)); + walkFiles(dir, root, results, ignoredDirs, (name) => MARKDOWN_PATH_REGEX.test(name)); } catch { /* fail soft for markdown — preserves existing behavior */ } diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 9b432c8f4..cd68e9c7f 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -376,7 +376,7 @@ const DirSection: React.FC<{ if (dir.tree.length === 0) { return (
- No markdown or text files found + No markdown, AsciiDoc, or text files found
); } diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index 38e739f55..3b614d50e 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -11,6 +11,7 @@ import type { Annotation, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; import type { SourceSaveCapability } from "@plannotator/core/source-save"; +import { documentFormatForPath, type DocumentFormat } from "@plannotator/core/document-format"; export interface LinkedDocLoadData { markdown?: string; @@ -39,6 +40,11 @@ export interface UseLinkedDocOptions { setRenderAs: (r: 'markdown' | 'html') => void; setRawHtml: (html: string) => void; setShareHtml: (html: string) => void; + /** Block-parser format of the base document (markdown vs asciidoc). Follows + * the same snapshot/restore lifecycle as renderAs — each linked/folder file + * derives its format from its extension, back() restores the base's. */ + docFormat: DocumentFormat; + setDocFormat: (f: DocumentFormat) => void; viewerRef: React.RefObject; sidebar: { open: (tab?: SidebarTab) => void }; /** Absolute path of the primary document — enables getDocAnnotations() to include @@ -66,6 +72,7 @@ interface SavedPlanState { renderAs: 'markdown' | 'html'; rawHtml: string; shareHtml: string; + docFormat: DocumentFormat; } export interface CachedDocState { @@ -129,6 +136,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs, setRawHtml, setShareHtml, + docFormat, + setDocFormat, viewerRef, sidebar, sourceFilePath, @@ -183,6 +192,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs(saved.renderAs); setRawHtml(saved.rawHtml); setShareHtml(saved.shareHtml); + setDocFormat(saved.docFormat); setMarkdown(saved.markdown); setAnnotations(saved.annotations); setGlobalAttachments(saved.globalAttachments); @@ -210,6 +220,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs, setRawHtml, setShareHtml, + setDocFormat, viewerRef, onBeforeNavigate, getDocumentMarkdown, @@ -250,6 +261,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { renderAs, rawHtml, shareHtml, + docFormat, }; let total = annotations.length + globalAttachments.length; for (const [fp, cached] of docCache.current.entries()) { @@ -290,6 +302,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs(docRenderAs); setRawHtml(docRenderAs === 'html' ? (data.rawHtml ?? '') : ''); setShareHtml(docRenderAs === 'html' ? (data.shareHtml ?? '') : ''); + setDocFormat(documentFormatForPath(data.filepath)); setMarkdown(docRenderAs === 'html' ? '' : nextMarkdown); setAnnotations(cached?.annotations ?? []); setGlobalAttachments(cached?.globalAttachments ?? []); @@ -317,6 +330,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { renderAs, rawHtml, shareHtml, + docFormat, linkedDoc, setMarkdown, setAnnotations, @@ -325,6 +339,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs, setRawHtml, setShareHtml, + setDocFormat, viewerRef, sidebar, sourceFilePath, @@ -400,6 +415,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { renderAs: savedPlanState.current.renderAs, rawHtml: savedPlanState.current.rawHtml, shareHtml: savedPlanState.current.shareHtml, + docFormat: savedPlanState.current.docFormat, annotations: [...savedPlanState.current.annotations], selectedAnnotationId: savedPlanState.current.selectedAnnotationId, globalAttachments: [...savedPlanState.current.globalAttachments], @@ -409,13 +425,14 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { renderAs, rawHtml, shareHtml, + docFormat, annotations: [...annotations], selectedAnnotationId, globalAttachments: [...globalAttachments], }; return { root, docs }; - }, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, shareHtml, selectedAnnotationId, getDocumentMarkdown]); + }, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, shareHtml, docFormat, selectedAnnotationId, getDocumentMarkdown]); const restoreSession = useCallback((state: LinkedDocSessionState) => { viewerRef.current?.clearAllHighlights(); @@ -432,6 +449,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs(state.root.renderAs); setRawHtml(state.root.rawHtml); setShareHtml(state.root.shareHtml); + setDocFormat(state.root.docFormat ?? 'markdown'); setAnnotations([...state.root.annotations]); setGlobalAttachments([...state.root.globalAttachments]); setSelectedAnnotationId(state.root.selectedAnnotationId); @@ -452,6 +470,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { setRenderAs, setRawHtml, setShareHtml, + setDocFormat, viewerRef, ]); diff --git a/packages/ui/hooks/useSharing.ts b/packages/ui/hooks/useSharing.ts index 67e7e5c77..4ab12780b 100644 --- a/packages/ui/hooks/useSharing.ts +++ b/packages/ui/hooks/useSharing.ts @@ -104,6 +104,8 @@ export function useSharing( setRawHtml?: (h: string) => void, setShareHtml?: (h: string) => void, setRenderAs?: (m: 'markdown' | 'html') => void, + docFormat?: 'markdown' | 'asciidoc', + setDocFormat?: (f: 'markdown' | 'asciidoc') => void, ): UseSharingResult { const [isSharedSession, setIsSharedSession] = useState(false); const [isLoadingShared, setIsLoadingShared] = useState(true); @@ -152,6 +154,7 @@ export function useSharing( setRawHtml?.(''); setShareHtml?.(''); } + setDocFormat?.(payload.f === 'adoc' ? 'asciidoc' : 'markdown'); const restoredAnnotations = fromShareable(payload.a, payload.d, payload.s); setAnnotations(restoredAnnotations); @@ -199,6 +202,7 @@ export function useSharing( setRawHtml?.(''); setShareHtml?.(''); } + setDocFormat?.(payload.f === 'adoc' ? 'asciidoc' : 'markdown'); // Convert shareable annotations to full annotations const restoredAnnotations = fromShareable(payload.a, payload.d, payload.s); @@ -237,7 +241,7 @@ export function useSharing( setShareLoadError('Failed to load shared plan — an unexpected error occurred.'); return false; } - }, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl, setRawHtml, setShareHtml, setRenderAs]); + }, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl, setRawHtml, setShareHtml, setRenderAs, setDocFormat]); // Load from hash on mount useEffect(() => { @@ -258,7 +262,7 @@ export function useSharing( // Generate share URL when markdown or annotations change const refreshShareUrl = useCallback(async () => { try { - const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml); + const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, docFormat); setShareUrl(url ?? ''); setShareUrlSize(url ? formatUrlSize(url) : ''); } catch (e) { @@ -266,7 +270,7 @@ export function useSharing( setShareUrl(''); setShareUrlSize(''); } - }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml]); + }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, docFormat]); // Auto-refresh share URL when dependencies change useEffect(() => { @@ -306,6 +310,7 @@ export function useSharing( globalAttachments, { pasteApiUrl, shareBaseUrl }, htmlForShare, + docFormat, ); if (result) { @@ -323,7 +328,7 @@ export function useSharing( } finally { setIsGeneratingShortUrl(false); } - }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare]); + }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare, docFormat]); // Import annotations from a teammate's share URL (supports both hash-based and short /p/ URLs) const importFromShareUrl = useCallback(async (url: string): Promise => { diff --git a/packages/ui/utils/asciidocParser.test.ts b/packages/ui/utils/asciidocParser.test.ts new file mode 100644 index 000000000..99579b944 --- /dev/null +++ b/packages/ui/utils/asciidocParser.test.ts @@ -0,0 +1,303 @@ +import { describe, test, expect } from 'bun:test'; +import { parseAsciidocToBlocks, parseDocumentToBlocks, transpileInline } from './asciidocParser'; +import { exportAnnotations } from './parser'; +import { AnnotationType, type Block } from '../types'; + +const parse = parseAsciidocToBlocks; + +const types = (blocks: Block[]) => blocks.map((b) => b.type); + +describe('parseAsciidocToBlocks — headings and header', () => { + test('document title and section levels map to heading levels', () => { + const blocks = parse('= Title\n\n== Section\n\n=== Sub\n\n====== Deep'); + expect(types(blocks)).toEqual(['heading', 'heading', 'heading', 'heading']); + expect(blocks.map((b) => b.level)).toEqual([1, 2, 3, 6]); + expect(blocks[0].content).toBe('Title'); + }); + + test('author and revision lines under the title are consumed', () => { + const blocks = parse('= Title\nJane Doe \nv1.0, 2026-01-01\n\nBody text.'); + expect(types(blocks)).toEqual(['heading', 'paragraph']); + expect(blocks[1].content).toBe('Body text.'); + }); + + test('attribute entries are consumed and substituted', () => { + const blocks = parse(':product: Plannotator\n\nUse {product} today. Unknown {nope} stays.'); + expect(types(blocks)).toEqual(['paragraph']); + expect(blocks[0].content).toBe('Use Plannotator today. Unknown {nope} stays.'); + }); + + test('line and block comments are skipped', () => { + const blocks = parse('// line comment\n\n////\nhidden\n////\n\nVisible.'); + expect(types(blocks)).toEqual(['paragraph']); + expect(blocks[0].content).toBe('Visible.'); + }); +}); + +describe('parseAsciidocToBlocks — listing blocks', () => { + test('[source,lang] + ---- becomes a code block with language', () => { + const blocks = parse('[source,rust]\n----\nfn main() {}\n----'); + expect(types(blocks)).toEqual(['code']); + expect(blocks[0].language).toBe('rust'); + expect(blocks[0].content).toBe('fn main() {}'); + // Span covers the attribute line through the closing delimiter. + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(4); + }); + + test('bare ---- listing and .... literal become code blocks', () => { + const blocks = parse('----\nplain listing\n----\n\n....\nliteral text\n....'); + expect(types(blocks)).toEqual(['code', 'code']); + expect(blocks[0].content).toBe('plain listing'); + expect(blocks[1].content).toBe('literal text'); + }); + + test('[source] on a plain paragraph makes it a listing', () => { + const blocks = parse('[source,js]\nconst x = 1;\n\nAfter.'); + expect(types(blocks)).toEqual(['code', 'paragraph']); + expect(blocks[0].language).toBe('js'); + expect(blocks[0].content).toBe('const x = 1;'); + }); + + test('code content is not inline-transpiled', () => { + const blocks = parse('----\n*not bold* link:x[y]\n----'); + expect(blocks[0].content).toBe('*not bold* link:x[y]'); + }); +}); + +describe('parseAsciidocToBlocks — admonitions', () => { + test('inline NOTE: paragraph becomes an alert blockquote', () => { + const blocks = parse('NOTE: Remember this\nand this too.\n\nNext.'); + expect(types(blocks)).toEqual(['blockquote', 'paragraph']); + expect(blocks[0].alertKind).toBe('note'); + expect(blocks[0].content).toBe('Remember this\nand this too.'); + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(2); + }); + + test('all five admonition kinds map', () => { + const blocks = parse( + 'NOTE: a\n\nTIP: b\n\nIMPORTANT: c\n\nWARNING: d\n\nCAUTION: e', + ); + expect(blocks.map((b) => b.alertKind)).toEqual(['note', 'tip', 'important', 'warning', 'caution']); + }); + + test('[WARNING] + ==== block form becomes an alert blockquote with full span', () => { + const blocks = parse('[WARNING]\n====\nDanger zone.\n\nStill inside.\n===='); + expect(types(blocks)).toEqual(['blockquote']); + expect(blocks[0].alertKind).toBe('warning'); + expect(blocks[0].content).toBe('Danger zone.\n\nStill inside.'); + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(6); + }); + + test('[NOTE] on a plain paragraph becomes an alert', () => { + const blocks = parse('[NOTE]\nJust a styled paragraph.'); + expect(types(blocks)).toEqual(['blockquote']); + expect(blocks[0].alertKind).toBe('note'); + }); +}); + +describe('parseAsciidocToBlocks — lists', () => { + test('unordered nesting via marker count', () => { + const blocks = parse('* one\n** two\n*** three\n- dash'); + expect(types(blocks)).toEqual(['list-item', 'list-item', 'list-item', 'list-item']); + expect(blocks.map((b) => b.level)).toEqual([0, 1, 2, 0]); + expect(blocks.map((b) => b.ordered)).toEqual([undefined, undefined, undefined, undefined]); + }); + + test('ordered lists via dot markers', () => { + const blocks = parse('. first\n.. nested\n. second'); + expect(blocks.map((b) => b.ordered)).toEqual([true, true, true]); + expect(blocks.map((b) => b.level)).toEqual([0, 1, 0]); + }); + + test('checklists', () => { + const blocks = parse('* [x] done\n* [*] also done\n* [ ] todo'); + expect(blocks.map((b) => b.checked)).toEqual([true, true, false]); + expect(blocks[0].content).toBe('done'); + }); + + test('description list becomes bold-term list item', () => { + const blocks = parse('CPU:: the processor\nRAM::\nworking memory'); + expect(types(blocks)).toEqual(['list-item', 'list-item']); + expect(blocks[0].content).toBe('**CPU** — the processor'); + expect(blocks[1].content).toBe('**RAM** — working memory'); + }); + + test('+ continuation attaches the next paragraph to the list item', () => { + const blocks = parse('* item\n+\ncontinued text\n\nSeparate.'); + expect(types(blocks)).toEqual(['list-item', 'paragraph']); + expect(blocks[0].content).toBe('item\n\ncontinued text'); + expect(blocks[1].content).toBe('Separate.'); + }); +}); + +describe('parseAsciidocToBlocks — tables', () => { + test('compact style: each line is a row, first row is header', () => { + const blocks = parse('|===\n|Name |Age\n|Ada |36\n|Alan |41\n|==='); + expect(types(blocks)).toEqual(['table']); + expect(blocks[0].content).toBe( + '| Name | Age |\n| --- | --- |\n| Ada | 36 |\n| Alan | 41 |', + ); + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(5); + }); + + test('blank-separated style: one cell per line groups into rows', () => { + const blocks = parse('|===\n|Name |Age\n\n|Ada\n|36\n\n|Alan\n|41\n|==='); + expect(blocks[0].content).toBe( + '| Name | Age |\n| --- | --- |\n| Ada | 36 |\n| Alan | 41 |', + ); + }); + + test('cell specs are stripped', () => { + const blocks = parse('|===\n|H1 |H2\n2+|span\n|==='); + expect(blocks[0].content).toContain('| span |'); + expect(blocks[0].content).not.toContain('2+'); + }); +}); + +describe('parseAsciidocToBlocks — quotes, rules, images, misc', () => { + test('____ quote block with [quote] attribution', () => { + const blocks = parse('[quote, Ada Lovelace]\n____\nThat brain of mine.\n____'); + expect(types(blocks)).toEqual(['blockquote']); + expect(blocks[0].content).toBe('That brain of mine.\n— Ada Lovelace'); + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(4); + }); + + test("''' and <<< become hr", () => { + const blocks = parse("above\n\n'''\n\n<<<\n\nbelow"); + expect(types(blocks)).toEqual(['paragraph', 'hr', 'hr', 'paragraph']); + }); + + test('block image macro becomes a markdown image paragraph', () => { + const blocks = parse('image::diagram.png[Architecture,600]'); + expect(types(blocks)).toEqual(['paragraph']); + expect(blocks[0].content).toBe('![Architecture](diagram.png)'); + }); + + test('example/sidebar delimiters are transparent grouping', () => { + const blocks = parse('====\ninside example\n====\n\n****\ninside sidebar\n****'); + expect(types(blocks)).toEqual(['paragraph', 'paragraph']); + expect(blocks[0].content).toBe('inside example'); + }); + + test('block title becomes a bold caption paragraph', () => { + const blocks = parse('.Fine Print\nThe details.'); + expect(types(blocks)).toEqual(['paragraph', 'paragraph']); + expect(blocks[0].content).toBe('**Fine Print**'); + }); + + test('unknown block macros degrade to literal paragraphs', () => { + const blocks = parse('toc::[]\n\ninclude::other.adoc[]'); + expect(types(blocks)).toEqual(['paragraph', 'paragraph']); + expect(blocks[0].content).toBe('toc::[]'); + expect(blocks[1].content).toBe('include::other.adoc[]'); + }); + + test('[[anchor]] lines fold into the next block span', () => { + const blocks = parse('[[intro]]\n== Introduction'); + expect(types(blocks)).toEqual(['heading']); + expect(blocks[0].startLine).toBe(1); + expect(blocks[0].sourceLineCount).toBe(2); + }); +}); + +describe('transpileInline', () => { + const t = (s: string) => transpileInline(s); + + test('constrained bold converts, double-star bold untouched', () => { + expect(t('this is *bold* text')).toBe('this is **bold** text'); + expect(t('already **bold** here')).toBe('already **bold** here'); + expect(t('2*3*4 math stays')).toBe('2*3*4 math stays'); + }); + + test('links', () => { + expect(t('see link:https://x.dev[the docs] now')).toBe('see [the docs](https://x.dev) now'); + expect(t('see https://x.dev[docs]')).toBe('see [docs](https://x.dev)'); + expect(t('bare https://x.dev stays')).toBe('bare https://x.dev stays'); + expect(t('link:guide.html[]')).toBe('[guide.html](guide.html)'); + }); + + test('inline image', () => { + expect(t('an image:icon.png[Icon] inline')).toBe('an ![Icon](icon.png) inline'); + }); + + test('xrefs and cross references degrade to text', () => { + expect(t('see xref:setup[Setup Guide]')).toBe('see Setup Guide'); + expect(t('see <>')).toBe('see the intro'); + expect(t('see <>')).toBe('see intro'); + }); + + test('footnotes become parentheticals', () => { + expect(t('a claim.footnote:[source needed]')).toBe('a claim. (source needed)'); + }); + + test('passthroughs strip markers', () => { + expect(t('keep ++++++ text')).toBe('keep text'); + expect(t('pass:[literal] here')).toBe('literal here'); + }); + + test('code spans are protected from transformation', () => { + expect(t('use `*argv` and *bold*')).toBe('use `*argv` and **bold**'); + expect(t('`link:x[y]` is literal')).toBe('`link:x[y]` is literal'); + }); +}); + +describe('line-number accuracy through exportAnnotations', () => { + test('annotation on a delimited block reports the full source span', () => { + const source = '= Doc\n\n[source,py]\n----\nprint(1)\nprint(2)\n----\n\ntail'; + const blocks = parse(source); + const code = blocks.find((b) => b.type === 'code')!; + expect(code.startLine).toBe(3); + expect(code.sourceLineCount).toBe(5); + + const out = exportAnnotations(blocks, [ + { + id: 'a1', + blockId: code.id, + startOffset: 0, + endOffset: 5, + type: AnnotationType.COMMENT, + text: 'why two prints?', + originalText: 'print(1)', + createdA: 1, + }, + ]); + expect(out).toContain('(lines 3–7)'); + }); + + test('annotation on a single-line paragraph reports one line', () => { + const blocks = parse('First.\n\nSecond paragraph.'); + const para = blocks[1]; + const out = exportAnnotations(blocks, [ + { + id: 'a1', + blockId: para.id, + startOffset: 0, + endOffset: 3, + type: AnnotationType.COMMENT, + text: 'ok', + originalText: 'Second', + createdA: 1, + }, + ]); + expect(out).toContain('(line 3)'); + }); +}); + +describe('parseDocumentToBlocks router', () => { + test('routes asciidoc and markdown to the right parser', () => { + const adoc = parseDocumentToBlocks('== Section', 'asciidoc'); + expect(adoc[0].type).toBe('heading'); + expect(adoc[0].level).toBe(2); + + const md = parseDocumentToBlocks('== Section', 'markdown'); + expect(md[0].type).toBe('paragraph'); + + const mdHeading = parseDocumentToBlocks('## Section', 'markdown'); + expect(mdHeading[0].type).toBe('heading'); + }); +}); diff --git a/packages/ui/utils/asciidocParser.ts b/packages/ui/utils/asciidocParser.ts new file mode 100644 index 000000000..70d6b376b --- /dev/null +++ b/packages/ui/utils/asciidocParser.ts @@ -0,0 +1,564 @@ +import { Block, type AlertKind } from '../types'; +import { parseMarkdownToBlocks } from './parser'; +import type { DocumentFormat } from '@plannotator/core/document-format'; + +/** + * A simplified AsciiDoc parser that splits content into the same linear Block + * model as `parseMarkdownToBlocks`. Block structure (sections, lists, listing + * blocks, admonitions, tables, quotes) is parsed natively; inline formatting + * is transpiled to markdown inline syntax because the Viewer's InlineMarkdown + * renderer speaks markdown. Anything unrecognized passes through literally — + * worst case is visible raw syntax, never lost (un-annotatable) content. + */ + +/** Pick the block parser for a document format. */ +export const parseDocumentToBlocks = (text: string, format: DocumentFormat): Block[] => + format === 'asciidoc' ? parseAsciidocToBlocks(text) : parseMarkdownToBlocks(text); + +const ADMONITION_KINDS = new Set(['note', 'tip', 'important', 'warning', 'caution']); + +/** Pending block metadata collected from attribute/anchor lines (`[source,js]`, + * `[NOTE]`, `[[anchor]]`, …) that attach to the NEXT block. `startLine` is the + * first metadata line so the block's exported line span covers it. */ +interface PendingMeta { + startLine: number; + style?: string; + language?: string; + admonition?: AlertKind; + quoteAttribution?: string; +} + +/** Substitute `{name}` attribute references. Only known attributes are + * replaced; unknown references stay literal (they may be plain braces). */ +const substituteAttributes = (text: string, attrs: Map): string => + text.replace(/\{([a-zA-Z0-9_][a-zA-Z0-9_-]*)\}/g, (whole, name: string) => { + const builtin = BUILTIN_ATTRIBUTES[name]; + if (builtin !== undefined) return builtin; + const value = attrs.get(name); + return value !== undefined ? value : whole; + }); + +const BUILTIN_ATTRIBUTES: Record = { + nbsp: ' ', + empty: '', + sp: ' ', + plus: '+', + asterisk: '*', + tilde: '~', + backtick: '`', + startsb: '[', + endsb: ']', + vbar: '|', +}; + +/** Transpile AsciiDoc inline syntax to markdown inline syntax for one + * non-code text segment. */ +const transpileSegment = (text: string, attrs: Map): string => { + let out = substituteAttributes(text, attrs); + + // Passthroughs: strip the markers, keep content verbatim-ish. + out = out.replace(/\+\+\+(.*?)\+\+\+/g, '$1'); + out = out.replace(/pass:[a-z,]*\[([^\]]*)\]/g, '$1'); + + // Inline image macro (single colon). Alt is the first positional attribute. + out = out.replace(/image:([^:\s\[][^\[\s]*)\[([^\]]*)\]/g, (_, path: string, attrList: string) => { + const alt = attrList.split(',')[0].trim(); + return `![${alt}](${path})`; + }); + + // Explicit link macro. + out = out.replace(/link:([^\s\[]+)\[([^\]]*)\]/g, (_, url: string, label: string) => { + const text = label.split(',')[0].trim(); + return `[${text || url}](${url})`; + }); + + // Bare URL followed directly by [text]. Bare URLs without brackets already + // autolink in InlineMarkdown. + out = out.replace(/(https?:\/\/[^\s\[\]]+)\[([^\]]*)\]/g, (_, url: string, label: string) => { + const text = label.split(',')[0].trim(); + return text ? `[${text}](${url})` : url; + }); + + // Cross references degrade to their text: heading anchors are slug-derived + // in the Viewer, so AsciiDoc ids won't resolve — plain text beats dead links. + out = out.replace(/xref:([^\s\[]+)\[([^\]]*)\]/g, (_, id: string, label: string) => label.trim() || id); + out = out.replace(/<<([^,>]+),\s*([^>]+)>>/g, '$2'); + out = out.replace(/<<([^,>]+)>>/g, '$1'); + + // Footnotes become parentheticals. + out = out.replace(/footnote:[\w-]*\[([^\]]*)\]/g, (_, note: string) => (note ? ` (${note})` : '')); + + // Constrained single-asterisk bold → markdown double-asterisk. `**x**` means + // bold in both syntaxes and is excluded by the inner char classes. + out = out.replace(/(^|[^\w*])\*([^\s*](?:[^*]*[^\s*])?)\*(?![\w*])/g, '$1**$2**'); + + return out; +}; + +/** Transpile inline AsciiDoc to markdown, protecting backtick code spans + * (odd split segments) from transformation. */ +export const transpileInline = (text: string, attrs: Map = new Map()): string => { + if (!text) return text; + return text + .split('`') + .map((segment, idx) => (idx % 2 === 0 ? transpileSegment(segment, attrs) : segment)) + .join('`'); +}; + +/** Strip simple cell specs (`2+|`, `a|`, `^|`, `.2+|`, …) so they don't leak + * into cell text, then split a physical table line into cells. */ +const splitTableCells = (line: string): string[] => { + const cleaned = line.replace( + /(^|\s)(?:\d+(?:\.\d+)?[+*]|\.\d+\+|[aemshldv]|[<^>](?:\.[<^>])?)?\|/g, + '$1|', + ); + const parts = cleaned.split('|'); + return parts.slice(1).map((cell) => cell.trim()); +}; + +const escapePipes = (cell: string): string => cell.replace(/\|/g, '\\|'); + +/** Convert collected `|===` body lines to a pipe-markdown table string. + * Row grouping: blank-line-separated groups are rows (one-cell-per-line + * style); with no blank lines, each physical line is a row. The first row is + * treated as the header. */ +const asciidocTableToPipeMarkdown = (bodyLines: string[], attrs: Map): string => { + const hasBlankSeparators = bodyLines.some((l) => l.trim() === ''); + const rows: string[][] = []; + + if (hasBlankSeparators) { + let current: string[] = []; + for (const line of bodyLines) { + if (line.trim() === '') { + if (current.length) rows.push(current); + current = []; + } else { + current.push(...splitTableCells(line)); + } + } + if (current.length) rows.push(current); + } else { + for (const line of bodyLines) { + if (line.trim() === '') continue; + rows.push(splitTableCells(line)); + } + } + + if (rows.length === 0) return ''; + + const columnCount = Math.max(...rows.map((r) => r.length)); + const normalize = (row: string[]): string[] => { + const cells = [...row]; + while (cells.length < columnCount) cells.push(''); + return cells.map((c) => escapePipes(transpileInline(c, attrs))); + }; + + const lines: string[] = []; + lines.push(`| ${normalize(rows[0]).join(' | ')} |`); + lines.push(`|${' --- |'.repeat(columnCount)}`); + for (const row of rows.slice(1)) { + lines.push(`| ${normalize(row).join(' | ')} |`); + } + return lines.join('\n'); +}; + +const AUTHOR_LINE_REGEX = /^[^\s<>][^<>]*<[^\s<>]+@[^\s<>]+>\s*$/; +const REVISION_LINE_REGEX = /^v?\d[\w.]*(?:,\s*.*)?$/; + +export const parseAsciidocToBlocks = (source: string): Block[] => { + const lines = source.split('\n'); + const blocks: Block[] = []; + const attrs = new Map(); + let currentId = 0; + + let buffer: string[] = []; + let bufferStartLine = 1; + let pendingMeta: PendingMeta | null = null; + // Set right after the document title is emitted so the optional author and + // revision lines directly below it are consumed instead of rendered. + let expectHeaderAuthorLines = false; + // Set by a lone `+` continuation line: the next paragraph appends to the + // preceding list item instead of becoming its own block. + let listContinuation = false; + + const takeMeta = (): PendingMeta | null => { + const meta = pendingMeta; + pendingMeta = null; + return meta; + }; + + const push = (block: Omit) => { + // Any explicit block emission cancels a dangling `+` continuation, so a + // stray continuation line never glues a later paragraph onto a list item. + listContinuation = false; + blocks.push({ ...block, id: `block-${currentId++}`, order: currentId }); + }; + + const flush = (endLine?: number) => { + if (buffer.length === 0) return; + const meta = takeMeta(); + const raw = buffer.join('\n').trim(); + const startLine = meta?.startLine ?? bufferStartLine; + const lastContentLine = endLine ?? bufferStartLine + buffer.length - 1; + const sourceSpan = lastContentLine - startLine + 1; + buffer = []; + + // `[source,lang]` (or `[listing]`/`[literal]`) styling a plain paragraph + // makes it a listing without delimiters. + if (meta?.style === 'source' || meta?.style === 'listing' || meta?.style === 'literal') { + push({ + type: 'code', + content: raw, + language: meta.language, + startLine, + sourceLineCount: sourceSpan, + }); + return; + } + + const content = transpileInline(raw, attrs); + const contentLineCount = content.split('\n').length; + + if (listContinuation && blocks.length > 0 && blocks[blocks.length - 1].type === 'list-item') { + blocks[blocks.length - 1].content += '\n\n' + content; + listContinuation = false; + return; + } + listContinuation = false; + + if (meta?.admonition) { + push({ + type: 'blockquote', + content, + alertKind: meta.admonition, + startLine, + sourceLineCount: sourceSpan, + }); + return; + } + push({ + type: 'paragraph', + content, + startLine, + sourceLineCount: sourceSpan !== contentLineCount ? sourceSpan : undefined, + }); + }; + + /** Consume lines i+1.. until a line matching `close`; returns [bodyLines, closingIndex]. */ + const collectDelimited = (startIdx: number, close: RegExp): [string[], number] => { + const body: string[] = []; + let j = startIdx; + while (j + 1 < lines.length) { + j++; + if (close.test(lines[j].trim())) return [body, j]; + body.push(lines[j]); + } + return [body, j]; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const currentLineNum = i + 1; + + // Optional author/revision lines directly under the document title. + if (expectHeaderAuthorLines) { + if (AUTHOR_LINE_REGEX.test(trimmed)) continue; + expectHeaderAuthorLines = false; + if (REVISION_LINE_REGEX.test(trimmed) && i >= 2 && AUTHOR_LINE_REGEX.test(lines[i - 1].trim())) { + continue; + } + } + + // Comment block: //// ... //// + if (/^\/{4,}$/.test(trimmed)) { + flush(currentLineNum - 1); + const [, closeIdx] = collectDelimited(i, /^\/{4,}$/); + i = closeIdx; + continue; + } + + // Line comment (but not a block-comment delimiter, handled above). + if (trimmed.startsWith('//')) continue; + + // Attribute entry: :name: value / :!name: — collected, no block. + const attrMatch = trimmed.match(/^:(!?)([a-zA-Z0-9_][a-zA-Z0-9_-]*)(!?):(?:\s+(.*))?$/); + if (attrMatch) { + flush(currentLineNum - 1); + const [, unsetPre, name, unsetPost, value] = attrMatch; + if (unsetPre || unsetPost) attrs.delete(name); + else attrs.set(name, (value ?? '').trim()); + continue; + } + + // Headings: = Title … ====== Sub. Level = count of '='. + const headingMatch = trimmed.match(/^(={1,6})\s+(.+)$/); + if (headingMatch) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const level = headingMatch[1].length; + push({ + type: 'heading', + content: transpileInline(headingMatch[2].trim(), attrs), + level, + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: meta ? currentLineNum - meta.startLine + 1 : undefined, + }); + if (level === 1 && blocks.length === 1) expectHeaderAuthorLines = true; + continue; + } + + // Thematic break / page break. + if (/^'{3,}$/.test(trimmed) || trimmed === '<<<') { + flush(currentLineNum - 1); + takeMeta(); + push({ type: 'hr', content: '', startLine: currentLineNum }); + continue; + } + + // Listing (----) and literal (....) delimited blocks → code. + const listingMatch = trimmed.match(/^(-{4,}|\.{4,})$/); + if (listingMatch) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const close = listingMatch[1].startsWith('-') ? /^-{4,}$/ : /^\.{4,}$/; + const [body, closeIdx] = collectDelimited(i, close); + push({ + type: 'code', + content: body.join('\n'), + language: meta?.language, + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: closeIdx + 1 - (meta?.startLine ?? currentLineNum) + 1, + }); + i = closeIdx; + continue; + } + + // Passthrough block (++++) → shown literally as a code block. + if (/^\+{4,}$/.test(trimmed)) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const [body, closeIdx] = collectDelimited(i, /^\+{4,}$/); + push({ + type: 'code', + content: body.join('\n'), + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: closeIdx + 1 - (meta?.startLine ?? currentLineNum) + 1, + }); + i = closeIdx; + continue; + } + + // Example block (====): admonition body when tagged [NOTE] etc.; otherwise + // a transparent grouping — the delimiters vanish and inner lines flow + // through the main loop (flat block model, like the md parser's quotes). + const exampleMatch = trimmed.match(/^={4,}$/); + if (exampleMatch) { + if (pendingMeta?.admonition) { + flush(currentLineNum - 1); + const meta = takeMeta()!; + const [body, closeIdx] = collectDelimited(i, /^={4,}$/); + push({ + type: 'blockquote', + content: transpileInline(body.join('\n').trim(), attrs), + alertKind: meta.admonition, + startLine: meta.startLine, + sourceLineCount: closeIdx + 1 - meta.startLine + 1, + }); + i = closeIdx; + } else { + flush(currentLineNum - 1); + } + continue; + } + + // Sidebar (****) and open (--) blocks: transparent grouping. + if (/^\*{4,}$/.test(trimmed) || trimmed === '--') { + flush(currentLineNum - 1); + continue; + } + + // Quote block (____) → blockquote, attribution from [quote, Name]. + if (/^_{4,}$/.test(trimmed)) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const [body, closeIdx] = collectDelimited(i, /^_{4,}$/); + let content = transpileInline(body.join('\n').trim(), attrs); + if (meta?.quoteAttribution) content += `\n— ${meta.quoteAttribution}`; + push({ + type: 'blockquote', + content, + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: closeIdx + 1 - (meta?.startLine ?? currentLineNum) + 1, + }); + i = closeIdx; + continue; + } + + // Table: |=== ... |=== + if (/^\|={3,}$/.test(trimmed)) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const [body, closeIdx] = collectDelimited(i, /^\|={3,}$/); + push({ + type: 'table', + content: asciidocTableToPipeMarkdown(body, attrs), + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: closeIdx + 1 - (meta?.startLine ?? currentLineNum) + 1, + }); + i = closeIdx; + continue; + } + + // Anchor line [[id]] → metadata for the next block. + if (/^\[\[[^\]]+\]\]$/.test(trimmed)) { + flush(currentLineNum - 1); + if (!pendingMeta) pendingMeta = { startLine: currentLineNum }; + continue; + } + + // Block attribute list: [source,js] / [NOTE] / [quote, Name] / [cols=…] … + const attrListMatch = trimmed.match(/^\[([^\]]*)\]$/); + if (attrListMatch) { + flush(currentLineNum - 1); + const parts = attrListMatch[1].split(',').map((p) => p.trim()); + const first = parts[0] ?? ''; + const meta: PendingMeta = pendingMeta ?? { startLine: currentLineNum }; + if (ADMONITION_KINDS.has(first.toLowerCase()) && first === first.toUpperCase()) { + meta.admonition = first.toLowerCase() as AlertKind; + } else if (first.toLowerCase() === 'source') { + meta.style = 'source'; + meta.language = parts[1] || undefined; + } else if (first.toLowerCase() === 'listing' || first.toLowerCase() === 'literal') { + meta.style = first.toLowerCase(); + } else if (first.toLowerCase() === 'quote' || first.toLowerCase() === 'verse') { + meta.quoteAttribution = parts.slice(1).filter(Boolean).join(', ') || undefined; + } + pendingMeta = meta; + continue; + } + + // Inline admonition paragraph: NOTE: text … + const admonitionMatch = trimmed.match(/^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\s+(.*)$/); + if (admonitionMatch) { + flush(currentLineNum - 1); + takeMeta(); + const startLine = currentLineNum; + const body: string[] = [admonitionMatch[2]]; + while (i + 1 < lines.length && lines[i + 1].trim() !== '') { + i++; + body.push(lines[i].trim()); + } + push({ + type: 'blockquote', + content: transpileInline(body.join('\n'), attrs), + alertKind: admonitionMatch[1].toLowerCase() as AlertKind, + startLine, + sourceLineCount: i + 1 - startLine + 1, + }); + continue; + } + + // Block image macro. + const blockImageMatch = trimmed.match(/^image::([^\[\s]+)\[([^\]]*)\]$/); + if (blockImageMatch) { + flush(currentLineNum - 1); + const meta = takeMeta(); + const alt = blockImageMatch[2].split(',')[0].trim(); + push({ + type: 'paragraph', + content: `![${alt}](${blockImageMatch[1]})`, + startLine: meta?.startLine ?? currentLineNum, + sourceLineCount: meta ? currentLineNum - meta.startLine + 1 : undefined, + }); + continue; + } + + // Unordered lists: * / ** / *** and the single-level - marker. + const unorderedMatch = line.match(/^\s*(\*{1,5}|-)\s+(.+)$/); + // Ordered lists: . / .. / ... + const orderedMatch = line.match(/^\s*(\.{1,5})\s+(.+)$/); + if (unorderedMatch || orderedMatch) { + flush(currentLineNum - 1); + takeMeta(); + const marker = (unorderedMatch ?? orderedMatch)![1]; + let content = (unorderedMatch ?? orderedMatch)![2]; + const ordered = !!orderedMatch; + const level = marker === '-' ? 0 : marker.length - 1; + + let checked: boolean | undefined = undefined; + const checkboxMatch = content.match(/^\[([ xX*])\]\s*/); + if (checkboxMatch) { + checked = checkboxMatch[1] !== ' '; + content = content.replace(/^\[([ xX*])\]\s*/, ''); + } + + push({ + type: 'list-item', + content: transpileInline(content, attrs), + level, + ordered: ordered || undefined, + checked, + startLine: currentLineNum, + }); + continue; + } + + // List continuation: a lone `+` attaches the next block to the previous item. + if (trimmed === '+' && blocks.length > 0 && blocks[blocks.length - 1].type === 'list-item' && buffer.length === 0) { + listContinuation = true; + continue; + } + + // Description list: term:: definition (definition may start on the next line). + const descMatch = trimmed.match(/^(\S.*?)::(?:\s+(.*))?$/); + if (descMatch && !trimmed.includes('::[')) { + flush(currentLineNum - 1); + takeMeta(); + const term = descMatch[1]; + const startLine = currentLineNum; + const defParts: string[] = descMatch[2] ? [descMatch[2]] : []; + if (defParts.length === 0) { + while (i + 1 < lines.length && lines[i + 1].trim() !== '' && !/^(\S.*?)::(?:\s+.*)?$/.test(lines[i + 1].trim())) { + i++; + defParts.push(lines[i].trim()); + } + } + const definition = transpileInline(defParts.join('\n'), attrs); + push({ + type: 'list-item', + content: `**${transpileInline(term, attrs)}**${definition ? ` — ${definition}` : ''}`, + level: 0, + startLine, + sourceLineCount: i + 1 - startLine + 1 > 1 ? i + 1 - startLine + 1 : undefined, + }); + continue; + } + + // Block title: .Title (dot + non-space, non-dot) → bold caption paragraph. + const blockTitleMatch = trimmed.match(/^\.([^\s.].*)$/); + if (blockTitleMatch && buffer.length === 0) { + flush(currentLineNum - 1); + push({ + type: 'paragraph', + content: `**${transpileInline(blockTitleMatch[1].trim(), attrs)}**`, + startLine: currentLineNum, + }); + continue; + } + + // Blank lines separate paragraphs. + if (trimmed === '') { + flush(currentLineNum - 1); + continue; + } + + // Accumulate paragraph text. + if (buffer.length === 0) bufferStartLine = currentLineNum; + buffer.push(line); + } + + flush(lines.length); + + return blocks; +}; diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 12317bc0a..4be810148 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -29,6 +29,7 @@ export interface SharePayload { s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a` h?: string; // raw HTML content (direct HTML rendering mode) r?: 'html'; // render mode flag (omitted = markdown) + f?: 'adoc'; // source document format flag (omitted = markdown) — picks the block parser on load } /** @@ -165,6 +166,7 @@ export async function generateShareUrl( globalAttachments?: ImageAttachment[], baseUrl: string = DEFAULT_SHARE_BASE, rawHtml?: string, + docFormat?: 'markdown' | 'asciidoc', ): Promise { // HTML content is too large for URL hashes — force paste service path if (rawHtml) return null; @@ -176,6 +178,7 @@ export async function generateShareUrl( g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined, ...(diffContexts ? { d: diffContexts } : {}), ...(sources ? { s: sources } : {}), + ...(docFormat === 'asciidoc' ? { f: 'adoc' as const } : {}), }; const hash = await compress(payload); @@ -247,6 +250,7 @@ export async function createShortShareUrl( shareBaseUrl?: string; }, rawHtml?: string, + docFormat?: 'markdown' | 'asciidoc', ): Promise<{ shortUrl: string; id: string } | null> { const pasteApi = options?.pasteApiUrl ?? DEFAULT_PASTE_API; const shareBase = options?.shareBaseUrl ?? DEFAULT_SHARE_BASE; @@ -261,6 +265,7 @@ export async function createShortShareUrl( ...(diffContexts ? { d: diffContexts } : {}), ...(sources ? { s: sources } : {}), ...(rawHtml ? { h: rawHtml, r: 'html' as const } : {}), + ...(docFormat === 'asciidoc' ? { f: 'adoc' as const } : {}), }; const compressed = await compress(payload);