@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 +6 -0
- package/dist/cli.mjs +111 -3
- package/dist/components.d.mts +2 -2
- package/package.json +1 -1
- package/src/annotation-html.ts +60 -0
- package/src/annotations.ts +224 -0
- package/src/assets/annotations.css +502 -0
- package/src/client/annotation-anchors.ts +270 -0
- package/src/client/annotation-view.ts +224 -0
- package/src/client/annotations.ts +582 -0
- package/src/client/doc-events.ts +1 -1
- package/src/client/entry.ts +2 -0
- package/src/html.ts +5 -1
- package/src/mdx.ts +5 -0
- package/src/remark/annotation-sources.ts +64 -0
- package/src/remark/include.ts +7 -0
- package/src/render.ts +8 -0
- package/src/tailwind.ts +1 -0
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,
|
package/dist/components.d.mts
CHANGED
|
@@ -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" | "
|
|
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" | "
|
|
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
|
@@ -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("&", "&")
|
|
92
|
+
.replaceAll("<", "<")
|
|
93
|
+
.replaceAll(">", ">")
|
|
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
|
+
};
|