@suzumiyaaoba/mdxr 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,12 @@ mdxr render plan.mdx --format json # machine-readable errors
37
37
 
38
38
  Preview a document in the browser with live reload (`-p, --port`, default `3737`; `--open` to launch the browser once serving).
39
39
 
40
+ ### Review the rendered document
41
+
42
+ Select text and click **Add comment**, or open **Annotate → Select figure** to comment on an image, diagram, or chart. Edit or delete comments in the panel, then **Copy Markdown** to send the quoted targets, source locations, and feedback to a coding agent.
43
+
44
+ Saved annotations remain in your browser for that document; they do not modify the source file or travel with the HTML. If the document changes, targets that cannot be identified keep their original quotes and are marked unavailable. [Review annotations](https://suzumiyaaoba.com/mdxr/annotations) describes keyboard shortcuts, storage, and clipboard fallbacks.
45
+
40
46
  ### `mdxr text [file]`
41
47
 
42
48
  Render an `.mdx` document to plain Markdown — every component becomes an ASCII/text stand-in (status checkbox lists, block-bar charts, GFM tables, `<details>` disclosures) so the document stays readable in any Markdown viewer or terminal. Interactive-only widgets (dialogs, menus, shadcn chrome) degrade to their text content; unknown components warn to stderr and keep their children.
package/dist/cli.mjs CHANGED
@@ -27,6 +27,7 @@ import { Fragment, createElement } from "react";
27
27
  import { icons } from "@iconify-json/lucide";
28
28
  import { icons as icons$1 } from "@iconify-json/vscode-icons";
29
29
  import { differenceInCalendarDays, isValid, parseISO } from "date-fns";
30
+ import { createHash } from "node:crypto";
30
31
  import { build } from "esbuild";
31
32
  import os from "node:os";
32
33
  import { spawn } from "node:child_process";
@@ -1298,6 +1299,10 @@ const remarkInclude = () => (tree, file) => {
1298
1299
  const expand = (parent, origin, chain) => {
1299
1300
  if (chain.length > 32) file.fail("Include: nesting exceeds 32 files");
1300
1301
  for (const child of parent.children) {
1302
+ child.data = {
1303
+ ...child.data,
1304
+ mdxrSourceFile: origin
1305
+ };
1301
1306
  if (isInclude(child)) {
1302
1307
  const included = includedDocument(child, origin, chain, file);
1303
1308
  expand(included.tree, included.abs, [...chain, included.abs]);
@@ -4675,6 +4680,67 @@ const mergeUserComponents = (mod, into = {}) => {
4675
4680
  else if (isComponent(val) && /^[A-Z]/u.test(key)) put(into, key, val);
4676
4681
  return into;
4677
4682
  };
4683
+ //#endregion
4684
+ //#region src/annotation-html.ts
4685
+ /** The pinned CSS optimizer does not parse custom highlights yet. Append these
4686
+ * native rules after optimization; limiting them to screens keeps print clean. */
4687
+ const ANNOTATION_HIGHLIGHT_CSS = `@media screen {
4688
+ ::highlight(mdxr-annotations) { background-color: #facc1559; }
4689
+ ::highlight(mdxr-annotation-active) { background-color: #38bdf859; }
4690
+ }`;
4691
+ /** Document chrome lives outside the hydrated MDX tree. All user text is filled via textContent. */
4692
+ const annotationHtml = (icon) => `
4693
+ <div id="mdxr-annotations" class="mdxr-annotations">
4694
+ <button type="button" class="mdxr-annotation-toggle" data-annotation-toggle aria-controls="mdxr-annotation-panel" aria-expanded="false">
4695
+ ${icon("message-square-text")} Annotate <span data-annotation-count>0</span>
4696
+ </button>
4697
+ <button type="button" class="mdxr-annotation-selection" data-annotation-selection hidden>${icon("message-square-plus")} Add comment</button>
4698
+ <aside id="mdxr-annotation-panel" aria-labelledby="mdxr-annotation-title" hidden>
4699
+ <header class="mdxr-annotation-header">
4700
+ <div class="mdxr-annotation-heading">
4701
+ <div class="mdxr-annotation-title-row"><h2 id="mdxr-annotation-title">Annotations</h2><span data-annotation-panel-count>0</span></div>
4702
+ <p class="mdxr-annotation-document" data-annotation-document></p>
4703
+ </div>
4704
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-pick aria-label="Select figure" title="Select figure" aria-pressed="false">${icon("scan")}</button>
4705
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-close aria-label="Close annotations" title="Close annotations (Esc)">${icon("x")}</button>
4706
+ </header>
4707
+ <div class="mdxr-annotation-scroll">
4708
+ <div class="mdxr-annotation-picker" data-annotation-figure-picker hidden>
4709
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-figure">Figure</label>
4710
+ <select id="mdxr-annotation-figure" data-annotation-figure></select>
4711
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-use-figure aria-label="Comment on figure" title="Comment on figure">${icon("arrow-up-right")}</button>
4712
+ </div>
4713
+ <form class="mdxr-annotation-composer" data-annotation-form aria-label="New comment" hidden>
4714
+ <blockquote data-annotation-draft-quote></blockquote>
4715
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-comment">Comment</label>
4716
+ <textarea id="mdxr-annotation-comment" data-annotation-comment rows="4" required placeholder="Comment…"></textarea>
4717
+ <div class="mdxr-annotation-composer-footer">
4718
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-cancel aria-label="Cancel" title="Cancel">${icon("x")}</button>
4719
+ <button type="submit" class="mdxr-annotation-submit" data-annotation-save aria-label="Save comment" title="Save comment (Ctrl / ⌘ + Enter)">${icon("arrow-up")}</button>
4720
+ </div>
4721
+ </form>
4722
+ <p class="mdxr-annotation-empty" data-annotation-empty>No comments</p>
4723
+ <ol class="mdxr-annotation-list" data-annotation-list></ol>
4724
+ </div>
4725
+ <footer class="mdxr-annotation-footer">
4726
+ <button type="button" data-annotation-copy aria-label="Copy Markdown" title="Copy Markdown" disabled><span class="mdxr-annotation-copy-idle">${icon("copy")}</span><span class="mdxr-annotation-copy-done">${icon("check")}</span><span data-annotation-copy-label>Markdown</span></button>
4727
+ <div class="mdxr-annotation-save-status" data-annotation-status-row data-state="saved"><p role="status" aria-live="polite" data-annotation-status></p></div>
4728
+ <div data-annotation-export hidden>
4729
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-markdown">Markdown feedback</label>
4730
+ <textarea id="mdxr-annotation-markdown" data-annotation-markdown rows="5" readonly></textarea>
4731
+ </div>
4732
+ </footer>
4733
+ </aside>
4734
+ <div class="mdxr-annotation-overlays" data-annotation-overlays aria-hidden="true"></div>
4735
+ <div data-annotation-icons hidden>
4736
+ <template data-annotation-icon="text">${icon("quote")}</template>
4737
+ <template data-annotation-icon="figure">${icon("image")}</template>
4738
+ <template data-annotation-icon="go">${icon("arrow-up-right")}</template>
4739
+ <template data-annotation-icon="edit">${icon("pencil-line")}</template>
4740
+ <template data-annotation-icon="delete">${icon("trash")}</template>
4741
+ <template data-annotation-icon="warning">${icon("triangle-alert")}</template>
4742
+ </div>
4743
+ </div>`;
4678
4744
  /** Mermaid bootstrap (module). Only injected when the document uses it. */
4679
4745
  const MERMAID_JS = `
4680
4746
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
@@ -4779,11 +4845,12 @@ const htmlDocument = (o) => `<!doctype html>
4779
4845
  <title>${escapeHtml(o.title)}</title>
4780
4846
  <script>${inlineScript(THEME_JS)}<\/script>
4781
4847
  ${o.needsKatex === true ? `<link rel="stylesheet" href="${KATEX_CDN_URL}">` : ""}
4782
- <style>${inlineStyle(o.css)}</style>
4848
+ <style>${inlineStyle(o.css)}${o.annotations === void 0 ? "" : ANNOTATION_HIGHLIGHT_CSS}</style>
4783
4849
  </head>
4784
4850
  <body class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
4785
4851
  ${THEME_TOGGLE_HTML}
4786
4852
  <main id="mdxr-root" class="prose prose-neutral dark:prose-invert mx-auto max-w-3xl px-6 py-10">${o.body}</main>
4853
+ ${o.annotations === void 0 ? "" : `${annotationHtml(iconSvg)}<script type="application/json" id="mdxr-annotation-document">${JSON.stringify(o.annotations).replaceAll("<", "\\u003c")}<\/script>`}
4787
4854
  <script>${inlineScript(o.clientJs)}<\/script>
4788
4855
  ${o.needsMermaid ? `<script type="module">${inlineScript(MERMAID_JS)}<\/script>` : ""}
4789
4856
  ${o.liveReload === true ? `<script>${inlineScript(LIVE_RELOAD_JS)}<\/script>` : ""}
@@ -5678,6 +5745,38 @@ const rehypeShiki = () => async (tree) => {
5678
5745
  })]);
5679
5746
  };
5680
5747
  //#endregion
5748
+ //#region src/remark/annotation-sources.ts
5749
+ /** A sidecar source index: no wrappers or props that change component children. */
5750
+ const remarkAnnotationSources = () => (tree, file) => {
5751
+ const sources = [];
5752
+ let heading = "";
5753
+ visit(tree, (node) => {
5754
+ if (node.type === "heading") heading = textContent$1(node).trim();
5755
+ if (node.position === void 0 || node.name === "Include" || ![
5756
+ "paragraph",
5757
+ "heading",
5758
+ "code",
5759
+ "image",
5760
+ "mdxJsxFlowElement",
5761
+ "mdxJsxTextElement"
5762
+ ].includes(node.type)) return;
5763
+ const origin = isRecord(node.data) ? node.data.mdxrSourceFile : void 0;
5764
+ const label = jsxAttr(node, "caption") ?? jsxAttr(node, "title") ?? jsxAttr(node, "aria-label") ?? jsxAttr(node, "alt") ?? "";
5765
+ const image = "url" in node && typeof node.url === "string" ? node.url : jsxAttr(node, "src") ?? "";
5766
+ const text = typeof node.value === "string" ? node.value : textContent$1(node);
5767
+ sources.push({
5768
+ end: node.position.end.line,
5769
+ file: typeof origin === "string" ? origin : file.path,
5770
+ heading,
5771
+ image,
5772
+ label,
5773
+ start: node.position.start.line,
5774
+ text
5775
+ });
5776
+ });
5777
+ file.data.annotationSources = sources;
5778
+ };
5779
+ //#endregion
5681
5780
  //#region src/mdx.ts
5682
5781
  /**
5683
5782
  * Which catalog entries the document references is visible in the compiled
@@ -5735,7 +5834,8 @@ const mdxToHtml = async (source, components, filePath = "document.mdx", opts = {
5735
5834
  remarkCodeFile,
5736
5835
  remarkCodeMeta,
5737
5836
  remarkFilePaths,
5738
- remarkReferences
5837
+ remarkReferences,
5838
+ remarkAnnotationSources
5739
5839
  ]
5740
5840
  });
5741
5841
  for (const m of compiled.messages) process.stderr.write(`mdxr: warning: ${formatError(m)}\n`);
@@ -5760,6 +5860,7 @@ const mdxToHtml = async (source, components, filePath = "document.mdx", opts = {
5760
5860
  throw enhanceRenderError(error, Object.keys(components));
5761
5861
  }
5762
5862
  return {
5863
+ annotationSources: compiled.data.annotationSources ?? [],
5763
5864
  body,
5764
5865
  code,
5765
5866
  context,
@@ -6251,6 +6352,7 @@ const importLine = (absPath) => `@import ${JSON.stringify(absPath.replaceAll("\\
6251
6352
  const buildCss = async (sources, themePath) => {
6252
6353
  const input = [
6253
6354
  "@import \"./src/styles/globals.css\";",
6355
+ "@import \"./src/assets/annotations.css\";",
6254
6356
  "@plugin \"@tailwindcss/typography\";",
6255
6357
  "@plugin \"@iconify/tailwind4\";",
6256
6358
  BASE_CSS,
@@ -6374,7 +6476,7 @@ const render = async (source, opts = {}) => {
6374
6476
  ...builtinComponents,
6375
6477
  ...user.components
6376
6478
  };
6377
- const { body, code, fileLinks, frontmatter, renderedAt, renderWithHeader, usedComponents, usedIcons } = await mdxToHtml(source, components, filePath, {
6479
+ const { annotationSources, body, code, fileLinks, frontmatter, renderedAt, renderWithHeader, usedComponents, usedIcons } = await mdxToHtml(source, components, filePath, {
6378
6480
  editor: config.editor,
6379
6481
  hydrate: opts.hydrate
6380
6482
  });
@@ -6434,6 +6536,12 @@ const render = async (source, opts = {}) => {
6434
6536
  usedIcons: docIcons
6435
6537
  })]);
6436
6538
  return htmlDocument({
6539
+ annotations: {
6540
+ file: filePath,
6541
+ revision: createHash("sha256").update(code).digest("hex"),
6542
+ sources: annotationSources,
6543
+ title
6544
+ },
6437
6545
  body: docBody,
6438
6546
  clientJs: js,
6439
6547
  css,
@@ -69,7 +69,7 @@ declare function AlertAction({ className, ...props }: React$2.ComponentProps<"di
69
69
  //#region src/components/ui/button.d.ts
70
70
  declare const buttonVariants: (props?: ({
71
71
  size?: "default" | "sm" | "xs" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | null | undefined;
72
- variant?: "default" | "destructive" | "link" | "ghost" | "outline" | "secondary" | null | undefined;
72
+ variant?: "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null | undefined;
73
73
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
74
74
  declare function Button({ className, variant, size, ...props }: Button$1.Props & VariantProps<typeof buttonVariants>): import("react").JSX.Element;
75
75
  //#endregion
@@ -126,7 +126,7 @@ declare function AvatarGroupCount({ className, ...props }: React$2.ComponentProp
126
126
  //#endregion
127
127
  //#region src/components/ui/badge.d.ts
128
128
  declare const badgeVariants: (props?: ({
129
- variant?: "default" | "destructive" | "link" | "ghost" | "outline" | "secondary" | null | undefined;
129
+ variant?: "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null | undefined;
130
130
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
131
131
  declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
132
132
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suzumiyaaoba/mdxr",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Render agent-authored MDX documents (plans, reports) to standalone HTML with a semantic component catalog",
5
5
  "keywords": [
6
6
  "agents",
@@ -0,0 +1,60 @@
1
+ /** The pinned CSS optimizer does not parse custom highlights yet. Append these
2
+ * native rules after optimization; limiting them to screens keeps print clean. */
3
+ export const ANNOTATION_HIGHLIGHT_CSS = `@media screen {
4
+ ::highlight(mdxr-annotations) { background-color: #facc1559; }
5
+ ::highlight(mdxr-annotation-active) { background-color: #38bdf859; }
6
+ }`;
7
+
8
+ /** Document chrome lives outside the hydrated MDX tree. All user text is filled via textContent. */
9
+ export const annotationHtml = (icon: (name: string) => string): string => `
10
+ <div id="mdxr-annotations" class="mdxr-annotations">
11
+ <button type="button" class="mdxr-annotation-toggle" data-annotation-toggle aria-controls="mdxr-annotation-panel" aria-expanded="false">
12
+ ${icon("message-square-text")} Annotate <span data-annotation-count>0</span>
13
+ </button>
14
+ <button type="button" class="mdxr-annotation-selection" data-annotation-selection hidden>${icon("message-square-plus")} Add comment</button>
15
+ <aside id="mdxr-annotation-panel" aria-labelledby="mdxr-annotation-title" hidden>
16
+ <header class="mdxr-annotation-header">
17
+ <div class="mdxr-annotation-heading">
18
+ <div class="mdxr-annotation-title-row"><h2 id="mdxr-annotation-title">Annotations</h2><span data-annotation-panel-count>0</span></div>
19
+ <p class="mdxr-annotation-document" data-annotation-document></p>
20
+ </div>
21
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-pick aria-label="Select figure" title="Select figure" aria-pressed="false">${icon("scan")}</button>
22
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-close aria-label="Close annotations" title="Close annotations (Esc)">${icon("x")}</button>
23
+ </header>
24
+ <div class="mdxr-annotation-scroll">
25
+ <div class="mdxr-annotation-picker" data-annotation-figure-picker hidden>
26
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-figure">Figure</label>
27
+ <select id="mdxr-annotation-figure" data-annotation-figure></select>
28
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-use-figure aria-label="Comment on figure" title="Comment on figure">${icon("arrow-up-right")}</button>
29
+ </div>
30
+ <form class="mdxr-annotation-composer" data-annotation-form aria-label="New comment" hidden>
31
+ <blockquote data-annotation-draft-quote></blockquote>
32
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-comment">Comment</label>
33
+ <textarea id="mdxr-annotation-comment" data-annotation-comment rows="4" required placeholder="Comment…"></textarea>
34
+ <div class="mdxr-annotation-composer-footer">
35
+ <button type="button" class="mdxr-annotation-icon-button" data-annotation-cancel aria-label="Cancel" title="Cancel">${icon("x")}</button>
36
+ <button type="submit" class="mdxr-annotation-submit" data-annotation-save aria-label="Save comment" title="Save comment (Ctrl / ⌘ + Enter)">${icon("arrow-up")}</button>
37
+ </div>
38
+ </form>
39
+ <p class="mdxr-annotation-empty" data-annotation-empty>No comments</p>
40
+ <ol class="mdxr-annotation-list" data-annotation-list></ol>
41
+ </div>
42
+ <footer class="mdxr-annotation-footer">
43
+ <button type="button" data-annotation-copy aria-label="Copy Markdown" title="Copy Markdown" disabled><span class="mdxr-annotation-copy-idle">${icon("copy")}</span><span class="mdxr-annotation-copy-done">${icon("check")}</span><span data-annotation-copy-label>Markdown</span></button>
44
+ <div class="mdxr-annotation-save-status" data-annotation-status-row data-state="saved"><p role="status" aria-live="polite" data-annotation-status></p></div>
45
+ <div data-annotation-export hidden>
46
+ <label class="mdxr-annotation-sr-only" for="mdxr-annotation-markdown">Markdown feedback</label>
47
+ <textarea id="mdxr-annotation-markdown" data-annotation-markdown rows="5" readonly></textarea>
48
+ </div>
49
+ </footer>
50
+ </aside>
51
+ <div class="mdxr-annotation-overlays" data-annotation-overlays aria-hidden="true"></div>
52
+ <div data-annotation-icons hidden>
53
+ <template data-annotation-icon="text">${icon("quote")}</template>
54
+ <template data-annotation-icon="figure">${icon("image")}</template>
55
+ <template data-annotation-icon="go">${icon("arrow-up-right")}</template>
56
+ <template data-annotation-icon="edit">${icon("pencil-line")}</template>
57
+ <template data-annotation-icon="delete">${icon("trash")}</template>
58
+ <template data-annotation-icon="warning">${icon("triangle-alert")}</template>
59
+ </div>
60
+ </div>`;
@@ -0,0 +1,224 @@
1
+ import { isRecord } from "./guards.js";
2
+
3
+ export interface AnnotationSource {
4
+ file: string;
5
+ start: number;
6
+ end: number;
7
+ text: string;
8
+ heading: string;
9
+ label: string;
10
+ image: string;
11
+ }
12
+
13
+ export interface AnnotationDocument {
14
+ file: string;
15
+ revision: string;
16
+ title: string;
17
+ sources: AnnotationSource[];
18
+ }
19
+
20
+ export interface AnnotationAnchor {
21
+ kind: "text" | "figure";
22
+ revision: string;
23
+ /** Child-element indexes from #mdxr-root; never an executable selector. */
24
+ path: number[];
25
+ quote: string;
26
+ heading: string;
27
+ prefix: string;
28
+ suffix: string;
29
+ start: number;
30
+ end: number;
31
+ image: string;
32
+ /** Original location, retained if the document later changes. */
33
+ source?: AnnotationSource;
34
+ }
35
+
36
+ export interface DocumentAnnotation {
37
+ id: string;
38
+ comment: string;
39
+ anchor: AnnotationAnchor;
40
+ }
41
+
42
+ export const normalizeAnnotationText = (text: string): string =>
43
+ text.replaceAll(/\s+/gu, " ").trim();
44
+
45
+ /** Report a source range only when the rendered target identifies one block. */
46
+ export const findAnnotationSource = (
47
+ anchor: AnnotationAnchor,
48
+ sources: readonly AnnotationSource[]
49
+ ): AnnotationSource | undefined => {
50
+ const quote = normalizeAnnotationText(anchor.quote);
51
+ let matches = sources.filter((source) =>
52
+ anchor.kind === "text"
53
+ ? quote !== "" && normalizeAnnotationText(source.text).includes(quote)
54
+ : (anchor.image !== "" && source.image === anchor.image) ||
55
+ (quote !== "" && normalizeAnnotationText(source.label) === quote)
56
+ );
57
+ if (matches.length > 1 && anchor.heading !== "") {
58
+ const contextual = matches.filter(
59
+ (source) => source.heading === anchor.heading
60
+ );
61
+ if (contextual.length > 0) {
62
+ matches = contextual;
63
+ }
64
+ }
65
+ // JSX containers and their paragraphs can describe the same source region.
66
+ matches = matches.filter(
67
+ (source) =>
68
+ !matches.some(
69
+ (other) =>
70
+ other !== source &&
71
+ other.file === source.file &&
72
+ other.start >= source.start &&
73
+ other.end <= source.end &&
74
+ other.end - other.start < source.end - source.start
75
+ )
76
+ );
77
+ const [first] = matches;
78
+ return first !== undefined &&
79
+ matches.every(
80
+ (source) =>
81
+ source.file === first.file &&
82
+ source.start === first.start &&
83
+ source.end === first.end
84
+ )
85
+ ? first
86
+ : undefined;
87
+ };
88
+
89
+ const markdownText = (value: string): string =>
90
+ value
91
+ .replaceAll("&", "&amp;")
92
+ .replaceAll("<", "&lt;")
93
+ .replaceAll(">", "&gt;")
94
+ .replaceAll(/(?<symbol>[\\`*_{}[\]()#+.!|>~-])/gu, "\\$<symbol>");
95
+
96
+ const codeSpan = (value: string): string => {
97
+ const runs = value.match(/`+/gu) ?? [];
98
+ const fence = "`".repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
99
+ return `${fence} ${value.replaceAll(/[\r\n]/gu, " ")} ${fence}`;
100
+ };
101
+
102
+ export const annotationLocation = (source: AnnotationSource): string =>
103
+ `${source.file}:${source.start}${source.end === source.start ? "" : `-${source.end}`}`;
104
+
105
+ /** Plain Markdown handoff; quotes are data, while comments retain Markdown. */
106
+ export const annotationsMarkdown = (
107
+ document: Pick<AnnotationDocument, "file" | "title">,
108
+ annotations: readonly DocumentAnnotation[],
109
+ detached: ReadonlySet<string> = new Set()
110
+ ): string => {
111
+ const lines = [
112
+ `# Feedback: ${markdownText(normalizeAnnotationText(document.title))}`,
113
+ "",
114
+ `Document: ${codeSpan(document.file)}`,
115
+ "",
116
+ "Please address the following review comments in the source document.",
117
+ ];
118
+ for (const [index, annotation] of annotations.entries()) {
119
+ const { anchor, comment } = annotation;
120
+ lines.push(
121
+ "",
122
+ `## ${index + 1}. ${anchor.kind === "text" ? "Text" : "Figure"} comment`,
123
+ ""
124
+ );
125
+ if (anchor.source !== undefined) {
126
+ lines.push(
127
+ `Source block: ${codeSpan(annotationLocation(anchor.source))}`
128
+ );
129
+ }
130
+ if (anchor.heading !== "") {
131
+ lines.push(`Section: ${markdownText(anchor.heading)}`);
132
+ }
133
+ if (anchor.image !== "") {
134
+ lines.push(`Image: ${codeSpan(anchor.image)}`);
135
+ }
136
+ if (detached.has(annotation.id)) {
137
+ lines.push(
138
+ "Target no longer found in the current rendering; the quote and source location below refer to the original selection."
139
+ );
140
+ }
141
+ lines.push(
142
+ "",
143
+ ...anchor.quote.split(/\r?\n/u).map((line) => `> ${markdownText(line)}`),
144
+ "",
145
+ "**Comment**",
146
+ "",
147
+ comment.trim()
148
+ );
149
+ }
150
+ return `${lines.join("\n")}\n`;
151
+ };
152
+
153
+ const isIndex = (value: unknown): value is number =>
154
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
155
+
156
+ const isSource = (value: unknown): value is AnnotationSource =>
157
+ isRecord(value) &&
158
+ ["file", "text", "heading", "label", "image"].every(
159
+ (key) => typeof value[key] === "string"
160
+ ) &&
161
+ isIndex(value.start) &&
162
+ value.start > 0 &&
163
+ isIndex(value.end) &&
164
+ value.end >= value.start;
165
+
166
+ const isAnchor = (value: unknown): value is AnnotationAnchor =>
167
+ isRecord(value) &&
168
+ (value.kind === "text" || value.kind === "figure") &&
169
+ Array.isArray(value.path) &&
170
+ value.path.every(isIndex) &&
171
+ ["quote", "heading", "prefix", "suffix", "image", "revision"].every(
172
+ (key) => typeof value[key] === "string"
173
+ ) &&
174
+ isIndex(value.start) &&
175
+ isIndex(value.end) &&
176
+ value.end >= value.start &&
177
+ (value.source === undefined || isSource(value.source));
178
+
179
+ const isAnnotation = (value: unknown): value is DocumentAnnotation =>
180
+ isRecord(value) &&
181
+ typeof value.id === "string" &&
182
+ value.id !== "" &&
183
+ typeof value.comment === "string" &&
184
+ value.comment.trim() !== "" &&
185
+ isAnchor(value.anchor);
186
+
187
+ export const parseAnnotations = (raw: string | null): DocumentAnnotation[] => {
188
+ if (raw === null) {
189
+ return [];
190
+ }
191
+ const value: unknown = JSON.parse(raw);
192
+ if (
193
+ !isRecord(value) ||
194
+ value.version !== 1 ||
195
+ !Array.isArray(value.annotations) ||
196
+ !value.annotations.every(isAnnotation)
197
+ ) {
198
+ throw new Error("Invalid saved annotations");
199
+ }
200
+ const annotations: DocumentAnnotation[] = value.annotations;
201
+ if (new Set(annotations.map(({ id }) => id)).size !== annotations.length) {
202
+ throw new Error("Duplicate annotation identifiers");
203
+ }
204
+ return annotations;
205
+ };
206
+
207
+ export const parseAnnotationDocument = (
208
+ raw: string
209
+ ): AnnotationDocument | undefined => {
210
+ const value: unknown = JSON.parse(raw);
211
+ return isRecord(value) &&
212
+ typeof value.file === "string" &&
213
+ typeof value.title === "string" &&
214
+ typeof value.revision === "string" &&
215
+ Array.isArray(value.sources) &&
216
+ value.sources.every(isSource)
217
+ ? {
218
+ file: value.file,
219
+ revision: value.revision,
220
+ sources: value.sources,
221
+ title: value.title,
222
+ }
223
+ : undefined;
224
+ };