{
// --- 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('');
+ });
+
+ 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  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 ``;
+ });
+
+ // 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: ``,
+ 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);