@brianmsm/quarto-bento 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +93 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +232 -0
- package/distribution/runtime/src/core/agent.ts +546 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +851 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2557 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +892 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +283 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +461 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { BentoDoc } from "./types.ts";
|
|
2
|
+
export const DOCUMENT_BLOCK =
|
|
3
|
+
/(<script\b[^>]*\bid\s*=\s*["']bento-doc["'][^>]*>)([\s\S]*?)(<\/script\s*>)/gi;
|
|
4
|
+
export function extractDocument(html: string): BentoDoc {
|
|
5
|
+
const matches = [...html.matchAll(DOCUMENT_BLOCK)];
|
|
6
|
+
if (matches.length !== 1) {
|
|
7
|
+
throw new Error("INVALID_HTML: expected exactly one bento-doc block");
|
|
8
|
+
}
|
|
9
|
+
const doc = JSON.parse(matches[0][2]);
|
|
10
|
+
if (
|
|
11
|
+
doc.format !== "bento/slides" || doc.version !== 1 || !doc.docId ||
|
|
12
|
+
!Array.isArray(doc.slides)
|
|
13
|
+
) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"INVALID_DOCUMENT: incompatible or encrypted Bento document",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (
|
|
19
|
+
doc.collab &&
|
|
20
|
+
!(Object.keys(doc.collab).sort().join(",") === "key,on,room" &&
|
|
21
|
+
doc.collab.room === "" && doc.collab.key === "" &&
|
|
22
|
+
doc.collab.on === false)
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"COLLABORATION_DISABLED: remove collaboration via an explicit independent copy",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const slides = new Set();
|
|
29
|
+
for (const slide of doc.slides) {
|
|
30
|
+
if (!slide.id || slides.has(slide.id) || !Array.isArray(slide.elements)) {
|
|
31
|
+
throw new Error("DUPLICATE_ID: invalid slide identity");
|
|
32
|
+
}
|
|
33
|
+
slides.add(slide.id);
|
|
34
|
+
const ids = new Set();
|
|
35
|
+
for (const el of slide.elements) {
|
|
36
|
+
if (!el.id || ids.has(el.id)) {
|
|
37
|
+
throw new Error("DUPLICATE_ID: invalid element identity");
|
|
38
|
+
}
|
|
39
|
+
ids.add(el.id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return doc;
|
|
43
|
+
}
|
|
44
|
+
export function spliceDocument(
|
|
45
|
+
shell: string,
|
|
46
|
+
doc: BentoDoc,
|
|
47
|
+
preboot = "",
|
|
48
|
+
csp = "",
|
|
49
|
+
): string {
|
|
50
|
+
if ([...shell.matchAll(DOCUMENT_BLOCK)].length !== 1) {
|
|
51
|
+
throw new Error("INVALID_SHELL: bento-doc missing or duplicated");
|
|
52
|
+
}
|
|
53
|
+
const json = JSON.stringify(doc).replaceAll("<", "\\u003c");
|
|
54
|
+
let html = shell.replace(
|
|
55
|
+
DOCUMENT_BLOCK,
|
|
56
|
+
(_whole, start, _old, end) => start + json + end,
|
|
57
|
+
);
|
|
58
|
+
if (preboot) {
|
|
59
|
+
if (/<\/script/i.test(preboot)) {
|
|
60
|
+
throw new Error("INVALID_BOOTSTRAP: closing script token");
|
|
61
|
+
}
|
|
62
|
+
html = html.replace(
|
|
63
|
+
/<head([^>]*)>/i,
|
|
64
|
+
`<head$1><script>${preboot}</script>`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (csp) {
|
|
68
|
+
html = html.replace(
|
|
69
|
+
/<head([^>]*)>/i,
|
|
70
|
+
`<head$1><meta http-equiv="Content-Security-Policy" content="${
|
|
71
|
+
escapeHtml(csp)
|
|
72
|
+
}">`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return html;
|
|
76
|
+
}
|
|
77
|
+
export function escapeHtml(value: string): string {
|
|
78
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(
|
|
79
|
+
">",
|
|
80
|
+
">",
|
|
81
|
+
).replaceAll('"', """);
|
|
82
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { assetData } from "./assets.ts";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { renderInline } from "./inline.ts";
|
|
4
|
+
import type { Node } from "./types.ts";
|
|
5
|
+
export type ImageAttr = [string, string[], [string, string][]];
|
|
6
|
+
export interface AuthoredImageShape {
|
|
7
|
+
target: string;
|
|
8
|
+
captionHtml: string;
|
|
9
|
+
captioned: boolean;
|
|
10
|
+
}
|
|
11
|
+
function fail(): never {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"UNSUPPORTED_AUTHORED_IMAGE: use one local standalone Markdown image with a bounded caption; image titles, numbered/cross-reference figures, extra HTML and transformed image structures need an explicit faithful adapter",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const plain = (attr: ImageAttr) =>
|
|
17
|
+
!attr[0] && !attr[1].length && !attr[2].length;
|
|
18
|
+
function image(
|
|
19
|
+
node: Node,
|
|
20
|
+
executed: boolean,
|
|
21
|
+
): { target: string; captionHtml: string } {
|
|
22
|
+
if (node.t !== "Image" || !Array.isArray(node.c) || node.c.length !== 3) {
|
|
23
|
+
return fail();
|
|
24
|
+
}
|
|
25
|
+
const [attr, alt, target] = node.c as [ImageAttr, Node[], [string, string]];
|
|
26
|
+
if (
|
|
27
|
+
!Array.isArray(attr) || !Array.isArray(alt) || !Array.isArray(target) ||
|
|
28
|
+
target.length !== 2 || typeof target[0] !== "string" || target[1] !== ""
|
|
29
|
+
) return fail();
|
|
30
|
+
if (
|
|
31
|
+
executed
|
|
32
|
+
? attr[0] || attr[2].length ||
|
|
33
|
+
JSON.stringify(attr[1]) !== JSON.stringify(["img-fluid"]) &&
|
|
34
|
+
!plain(attr)
|
|
35
|
+
: !plain(attr)
|
|
36
|
+
) return fail();
|
|
37
|
+
return { target: target[0], captionHtml: renderInline(alt) };
|
|
38
|
+
}
|
|
39
|
+
function singleImage(blocks: Node[], executed: boolean) {
|
|
40
|
+
if (blocks.length !== 1 || !["Plain", "Para"].includes(blocks[0].t)) {
|
|
41
|
+
return fail();
|
|
42
|
+
}
|
|
43
|
+
const content = blocks[0].c as Node[];
|
|
44
|
+
if (!Array.isArray(content) || content.length !== 1) return fail();
|
|
45
|
+
return image(content[0], executed);
|
|
46
|
+
}
|
|
47
|
+
/** Original Pandoc Figure is checked independently from Quarto's generated HTML. */
|
|
48
|
+
export function originalImageShape(node: Node): AuthoredImageShape {
|
|
49
|
+
if (node.t === "Para" || node.t === "Plain") {
|
|
50
|
+
const value = singleImage([node], false);
|
|
51
|
+
if (value.captionHtml) return fail();
|
|
52
|
+
return { ...value, captioned: false };
|
|
53
|
+
}
|
|
54
|
+
if (node.t !== "Figure" || !Array.isArray(node.c) || node.c.length !== 3) {
|
|
55
|
+
return fail();
|
|
56
|
+
}
|
|
57
|
+
const [attr, caption, blocks] = node.c as [ImageAttr, [null, Node[]], Node[]];
|
|
58
|
+
if (
|
|
59
|
+
!plain(attr) || !Array.isArray(caption) || caption.length !== 2 ||
|
|
60
|
+
caption[0] !== null || caption[1].length !== 1 ||
|
|
61
|
+
!["Plain", "Para"].includes(caption[1][0].t)
|
|
62
|
+
) return fail();
|
|
63
|
+
const value = singleImage(blocks, false);
|
|
64
|
+
const captionHtml = renderInline(caption[1][0].c as Node[]);
|
|
65
|
+
if (!captionHtml || captionHtml !== value.captionHtml) return fail();
|
|
66
|
+
return { ...value, captioned: true };
|
|
67
|
+
}
|
|
68
|
+
const raw = (node: Node, kind: "RawBlock" | "RawInline", expected: string) =>
|
|
69
|
+
node.t === kind &&
|
|
70
|
+
JSON.stringify(node.c) === JSON.stringify(["html", expected]);
|
|
71
|
+
/** Accept only the known Quarto image wrapper, never general raw HTML. */
|
|
72
|
+
export function executedImageShape(node: Node): AuthoredImageShape {
|
|
73
|
+
if (node.t === "Figure") return originalImageShape(node);
|
|
74
|
+
if (node.t === "Para" || node.t === "Plain") {
|
|
75
|
+
const value = singleImage([node], true);
|
|
76
|
+
if (value.captionHtml) return fail();
|
|
77
|
+
return { ...value, captioned: false };
|
|
78
|
+
}
|
|
79
|
+
if (node.t !== "Div" || !Array.isArray(node.c) || node.c.length !== 2) {
|
|
80
|
+
return fail();
|
|
81
|
+
}
|
|
82
|
+
const [attr, children] = node.c as [ImageAttr, Node[]];
|
|
83
|
+
if (
|
|
84
|
+
attr[0] || attr[2].length ||
|
|
85
|
+
JSON.stringify([...attr[1]].sort()) !==
|
|
86
|
+
JSON.stringify(["quarto-figure", "quarto-figure-center"])
|
|
87
|
+
) return fail();
|
|
88
|
+
if (
|
|
89
|
+
children.length !== 4 || !raw(children[0], "RawBlock", "<figure>") ||
|
|
90
|
+
!raw(children[3], "RawBlock", "</figure>") || children[2].t !== "Plain"
|
|
91
|
+
) return fail();
|
|
92
|
+
const value = singleImage([children[1]], true);
|
|
93
|
+
if (value.captionHtml) return fail();
|
|
94
|
+
const caption = children[2].c as Node[];
|
|
95
|
+
if (
|
|
96
|
+
!Array.isArray(caption) || caption.length < 3 ||
|
|
97
|
+
!raw(caption[0], "RawInline", "<figcaption>") ||
|
|
98
|
+
!raw(caption.at(-1)!, "RawInline", "</figcaption>")
|
|
99
|
+
) return fail();
|
|
100
|
+
const captionHtml = renderInline(caption.slice(1, -1));
|
|
101
|
+
if (!captionHtml) return fail();
|
|
102
|
+
return { target: value.target, captionHtml, captioned: true };
|
|
103
|
+
}
|
|
104
|
+
export function assertImageShape(
|
|
105
|
+
original: AuthoredImageShape,
|
|
106
|
+
executed: AuthoredImageShape,
|
|
107
|
+
) {
|
|
108
|
+
if (JSON.stringify(original) !== JSON.stringify(executed)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"UNMAPPED_AUTHORED_IMAGE: executed image path or caption differs from the exact original source",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function assertAuthoredSvgData(src: string) {
|
|
115
|
+
if (!src.startsWith("data:image/svg+xml;base64,")) return;
|
|
116
|
+
const svg = new TextDecoder().decode(
|
|
117
|
+
Uint8Array.from(atob(src.split(",")[1]), (c) => c.charCodeAt(0)),
|
|
118
|
+
);
|
|
119
|
+
if (/<!DOCTYPE|<!ENTITY|<(?:script|foreignObject)\b|\bon\w+\s*=/i.test(svg)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"UNSAFE_SVG: authored images must be self-contained and script-free",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const css = [...svg.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((
|
|
125
|
+
match,
|
|
126
|
+
) => match[1]);
|
|
127
|
+
css.push(
|
|
128
|
+
...[...svg.matchAll(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((
|
|
129
|
+
match,
|
|
130
|
+
) => match[1] ?? match[2]),
|
|
131
|
+
);
|
|
132
|
+
for (const value of css) {
|
|
133
|
+
const decoded = value.replace(
|
|
134
|
+
/&#(?:x([0-9a-f]+)|([0-9]+));/gi,
|
|
135
|
+
(_whole, hex: string, decimal: string) => {
|
|
136
|
+
const point = parseInt(hex ?? decimal, hex ? 16 : 10);
|
|
137
|
+
if (
|
|
138
|
+
point > 0x10ffff || point === 0 || point >= 0xd800 && point <= 0xdfff
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"UNSAFE_SVG: invalid XML character reference in style",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return String.fromCodePoint(point);
|
|
145
|
+
},
|
|
146
|
+
).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
147
|
+
if (/@|\\/.test(decoded)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"UNSAFE_SVG: authored image styles cannot use at-rules or escaped dependency syntax",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
for (const match of decoded.matchAll(/url\(([^)]*)\)/gi)) {
|
|
153
|
+
if (!/^\s*["']?#[^\s"']+["']?\s*$/.test(match[1])) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"UNSAFE_SVG: authored image styles must use only internal fragment references",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const match of svg.matchAll(/\b(?:href|src)\s*=\s*["']([^"']*)["']/gi)) {
|
|
161
|
+
if (
|
|
162
|
+
!/^#[^\s]*$/.test(match[1]) &&
|
|
163
|
+
!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(match[1])
|
|
164
|
+
) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"UNSAFE_SVG: authored images cannot reference external or local companion resources",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const match of svg.matchAll(/url\(([^)]*)\)/gi)) {
|
|
171
|
+
if (!/^\s*["']?#[^\s"']+["']?\s*$/.test(match[1])) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"UNSAFE_SVG: authored image styles must use only internal fragment references",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Read only the source file, not a generated build file sharing its name. */
|
|
180
|
+
export async function authoredImageData(
|
|
181
|
+
shape: AuthoredImageShape,
|
|
182
|
+
sourceRoot: string,
|
|
183
|
+
) {
|
|
184
|
+
if (/[?#]/.test(shape.target)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"UNSUPPORTED_AUTHORED_IMAGE: image fragments/query semantics need a dedicated adapter",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
let file: string;
|
|
190
|
+
try {
|
|
191
|
+
file = decodeURIComponent(shape.target);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error("INVALID_IMAGE_PATH: malformed escaped local path");
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
!file || isAbsolute(file) || /^[a-z][a-z0-9+.-]*:/i.test(file) ||
|
|
197
|
+
/^[/\\]{2}/.test(file) || file.includes("\0")
|
|
198
|
+
) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"EXTERNAL_ASSET: authored image must use a relative local source asset",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const data = await assetData(shape.target, [sourceRoot]);
|
|
204
|
+
assertAuthoredSvgData(data);
|
|
205
|
+
return data;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
import type { SourceBlock, SourceDocument } from "../source/mod.ts";
|
|
209
|
+
import {
|
|
210
|
+
originalAttributeTokens,
|
|
211
|
+
originalPandocAttributes,
|
|
212
|
+
} from "../source/attributes.ts";
|
|
213
|
+
import { isNativeIdentity } from "../source/identity-id.ts";
|
|
214
|
+
export function imageNativeIdentities(
|
|
215
|
+
source: SourceDocument,
|
|
216
|
+
block: SourceBlock,
|
|
217
|
+
) {
|
|
218
|
+
const parent = block.parentId
|
|
219
|
+
? source.blocks.find((candidate) => candidate.id === block.parentId)
|
|
220
|
+
: undefined;
|
|
221
|
+
const attributes = parent
|
|
222
|
+
? originalPandocAttributes(
|
|
223
|
+
parent.raw.split(/\r?\n/)[0].replace(/^\s*:{3,}\s*\{/, "").replace(
|
|
224
|
+
/\}\s*$/,
|
|
225
|
+
"",
|
|
226
|
+
),
|
|
227
|
+
originalAttributeTokens(
|
|
228
|
+
parent.raw.split(/\r?\n/)[0].replace(/^\s*:{3,}\s*\{/, "").replace(
|
|
229
|
+
/\}\s*$/,
|
|
230
|
+
"",
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
: undefined;
|
|
235
|
+
const caption = attributes?.[2].find(([name]) => name === "bento-element-id")
|
|
236
|
+
?.[1];
|
|
237
|
+
const image = attributes?.[2].find(([name]) => name === "bento-image-id")
|
|
238
|
+
?.[1];
|
|
239
|
+
if (caption !== undefined || image !== undefined) {
|
|
240
|
+
if (
|
|
241
|
+
!caption || !image || !isNativeIdentity(caption) ||
|
|
242
|
+
!isNativeIdentity(image) || caption === image
|
|
243
|
+
) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
"INVALID_IMAGE_BINDING: source image pair needs distinct exact caption and image IDs",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
imageId: image,
|
|
250
|
+
captionId: caption,
|
|
251
|
+
groupId: `${image}-figure`,
|
|
252
|
+
attributes,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const imageId = block.stable || parent?.stable
|
|
256
|
+
? block.id
|
|
257
|
+
: `qb-image-${
|
|
258
|
+
source.blocks.filter((candidate) => candidate.kind === "image").findIndex(
|
|
259
|
+
(candidate) => candidate.id === block.id,
|
|
260
|
+
) + 1
|
|
261
|
+
}`;
|
|
262
|
+
return {
|
|
263
|
+
imageId,
|
|
264
|
+
captionId: `${imageId}-caption`,
|
|
265
|
+
groupId: `${imageId}-figure`,
|
|
266
|
+
attributes,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** Faithful bounded rich-text vocabulary shared by projection and explicit sync. */
|
|
2
|
+
import { StateError } from "../state/mod.ts";
|
|
3
|
+
import { escapeHtml } from "./html.ts";
|
|
4
|
+
import type { Node } from "./types.ts";
|
|
5
|
+
export const INLINE_CODEC_VERSION = 1;
|
|
6
|
+
type Attr = [string, string[], [string, string][]];
|
|
7
|
+
function fail(message: string): never {
|
|
8
|
+
throw new StateError("UNSUPPORTED_HTML", message);
|
|
9
|
+
}
|
|
10
|
+
function decodeEntities(value: string): string {
|
|
11
|
+
return value.replace(/&([^;\s]+);/g, (_whole, name: string) => {
|
|
12
|
+
const named: Record<string, string> = {
|
|
13
|
+
amp: "&",
|
|
14
|
+
lt: "<",
|
|
15
|
+
gt: ">",
|
|
16
|
+
quot: '"',
|
|
17
|
+
apos: "'",
|
|
18
|
+
nbsp: "\u00a0",
|
|
19
|
+
};
|
|
20
|
+
if (Object.hasOwn(named, name)) return named[name];
|
|
21
|
+
if (/^#(?:[0-9]+|x[0-9a-f]+)$/i.test(name)) {
|
|
22
|
+
const point = name[1].toLowerCase() === "x"
|
|
23
|
+
? parseInt(name.slice(2), 16)
|
|
24
|
+
: parseInt(name.slice(1), 10);
|
|
25
|
+
if (
|
|
26
|
+
point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
|
|
27
|
+
) {
|
|
28
|
+
return String.fromCodePoint(point);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return fail(`Unsupported or invalid HTML entity &${name};.`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function webLink(href: string): string {
|
|
35
|
+
if (
|
|
36
|
+
!/^https?:\/\//i.test(href) || /[\s<>"'\\]/u.test(href) ||
|
|
37
|
+
Array.from(href).some((c) =>
|
|
38
|
+
c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127
|
|
39
|
+
)
|
|
40
|
+
) {
|
|
41
|
+
fail(
|
|
42
|
+
"Only explicit http/https links without control characters are supported by native Bento.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
if (!new URL(href).hostname) fail("Link has no hostname.");
|
|
47
|
+
} catch {
|
|
48
|
+
fail("Link is not a valid web URL.");
|
|
49
|
+
}
|
|
50
|
+
return href;
|
|
51
|
+
}
|
|
52
|
+
function plainAttributes(attr: Attr): boolean {
|
|
53
|
+
return !attr[0] && !attr[1].length && !attr[2].length;
|
|
54
|
+
}
|
|
55
|
+
export function renderInline(nodes: Node[]): string {
|
|
56
|
+
return nodes.map((node) => {
|
|
57
|
+
switch (node.t) {
|
|
58
|
+
case "Str":
|
|
59
|
+
return escapeHtml(String(node.c));
|
|
60
|
+
case "Space":
|
|
61
|
+
case "SoftBreak":
|
|
62
|
+
return " ";
|
|
63
|
+
case "LineBreak":
|
|
64
|
+
return "<br>";
|
|
65
|
+
case "Strong":
|
|
66
|
+
return `<b>${renderInline(node.c as Node[])}</b>`;
|
|
67
|
+
case "Emph":
|
|
68
|
+
return `<i>${renderInline(node.c as Node[])}</i>`;
|
|
69
|
+
case "Underline":
|
|
70
|
+
return `<u>${renderInline(node.c as Node[])}</u>`;
|
|
71
|
+
case "Strikeout":
|
|
72
|
+
return `<s>${renderInline(node.c as Node[])}</s>`;
|
|
73
|
+
case "Code": {
|
|
74
|
+
const [attr, code] = node.c as [Attr, string];
|
|
75
|
+
if (!plainAttributes(attr)) {
|
|
76
|
+
fail("Inline code attributes need an explicit supported projection.");
|
|
77
|
+
}
|
|
78
|
+
return `<code>${escapeHtml(code)}</code>`;
|
|
79
|
+
}
|
|
80
|
+
case "Span": {
|
|
81
|
+
const [attr, content] = node.c as [Attr, Node[]];
|
|
82
|
+
if (!plainAttributes(attr)) {
|
|
83
|
+
fail(
|
|
84
|
+
"Inline span identities, classes and styles need an explicit supported projection.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return renderInline(content);
|
|
88
|
+
}
|
|
89
|
+
case "Link": {
|
|
90
|
+
const [attr, content, [href, title]] = node.c as [
|
|
91
|
+
Attr,
|
|
92
|
+
Node[],
|
|
93
|
+
[string, string],
|
|
94
|
+
];
|
|
95
|
+
if (!plainAttributes(attr) || title) {
|
|
96
|
+
fail(
|
|
97
|
+
"Native Bento cannot retain link titles or extra link attributes; edit the source explicitly.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return `<a href="${escapeHtml(webLink(href))}">${
|
|
101
|
+
renderInline(content)
|
|
102
|
+
}</a>`;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
throw new Error(
|
|
106
|
+
`UNSUPPORTED_INLINE: ${node.t}; no faithful projection implemented`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}).join("");
|
|
110
|
+
}
|
|
111
|
+
const escapeMarkdown = (text: string) =>
|
|
112
|
+
text.replace(/[\\`*_[\]{}<>!#$|~^@]/g, "\\$&");
|
|
113
|
+
function codeSpan(text: string): string {
|
|
114
|
+
if (!text || /[\r\n\t]|^\s|\s$| {2,}/.test(text)) {
|
|
115
|
+
fail(
|
|
116
|
+
"Inline code with empty text or significant whitespace needs explicit source review; Pandoc normalizes those spaces.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const longest = Math.max(
|
|
120
|
+
0,
|
|
121
|
+
...(text.match(/`+/g) ?? []).map((s) => s.length),
|
|
122
|
+
);
|
|
123
|
+
const delimiter = "`".repeat(longest + 1);
|
|
124
|
+
const pad = /^`|`$/.test(text) ? " " : "";
|
|
125
|
+
return delimiter + pad + text + pad + delimiter;
|
|
126
|
+
}
|
|
127
|
+
interface OpenTag {
|
|
128
|
+
tag: string;
|
|
129
|
+
kind: "b" | "i" | "s" | "code" | "a" | "span";
|
|
130
|
+
text: string;
|
|
131
|
+
href?: string;
|
|
132
|
+
}
|
|
133
|
+
/** Parse only the reviewed subset. Unsupported markup never disappears silently. */
|
|
134
|
+
function convertInline(
|
|
135
|
+
html: string,
|
|
136
|
+
allowEmpty = false,
|
|
137
|
+
): { markdown: string; html: string } {
|
|
138
|
+
if (html.length > 1_000_000 || html.includes("\u0000")) {
|
|
139
|
+
fail("Invalid or excessive text fragment.");
|
|
140
|
+
}
|
|
141
|
+
let input = html.trim();
|
|
142
|
+
const wrapper = input.match(/^<(p|div)>([\s\S]*)<\/\1>$/i);
|
|
143
|
+
if (wrapper && !new RegExp(`<\\/?${wrapper[1]}\\b`, "i").test(wrapper[2])) {
|
|
144
|
+
input = wrapper[2];
|
|
145
|
+
}
|
|
146
|
+
const tokens = input.match(/<[^>]*>|[^<]+|</g) ?? [];
|
|
147
|
+
let open: OpenTag | undefined, markdown = "", canonical = "";
|
|
148
|
+
for (const token of tokens) {
|
|
149
|
+
if (!token.startsWith("<")) {
|
|
150
|
+
const raw = decodeEntities(token);
|
|
151
|
+
const text = open?.kind === "code"
|
|
152
|
+
? raw
|
|
153
|
+
: raw.replace(/[\r\n\t ]+/g, " ");
|
|
154
|
+
if (open) open.text += text;
|
|
155
|
+
else {
|
|
156
|
+
markdown += escapeMarkdown(text);
|
|
157
|
+
canonical += escapeHtml(text);
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (/^<br\s*\/?\s*>$/i.test(token)) {
|
|
162
|
+
if (open) {
|
|
163
|
+
fail("Line breaks inside formatted spans need explicit source review.");
|
|
164
|
+
}
|
|
165
|
+
markdown += " \n";
|
|
166
|
+
canonical += "<br>";
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const end = token.match(/^<\/([a-z]+)\s*>$/i);
|
|
170
|
+
if (end) {
|
|
171
|
+
if (!open || open.tag !== end[1].toLowerCase()) {
|
|
172
|
+
fail("HTML tags are unbalanced.");
|
|
173
|
+
}
|
|
174
|
+
if (!open.text || (open.kind !== "code" && /^\s|\s$/.test(open.text))) {
|
|
175
|
+
fail(
|
|
176
|
+
"Empty formatting or boundary whitespace needs explicit source review.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (open.kind === "span") markdown += escapeMarkdown(open.text);
|
|
180
|
+
else if (open.kind === "code") markdown += codeSpan(open.text);
|
|
181
|
+
else if (open.kind === "a") {
|
|
182
|
+
markdown += `[${escapeMarkdown(open.text)}](<${open.href}>)`;
|
|
183
|
+
} else {
|
|
184
|
+
const mark = ({ b: "**", i: "*", s: "~~" })[open.kind];
|
|
185
|
+
markdown += mark + escapeMarkdown(open.text) + mark;
|
|
186
|
+
}
|
|
187
|
+
canonical += open.kind === "span"
|
|
188
|
+
? escapeHtml(open.text)
|
|
189
|
+
: `<${open.kind}${
|
|
190
|
+
open.kind === "a" ? ` href="${escapeHtml(open.href!)}"` : ""
|
|
191
|
+
}>${escapeHtml(open.text)}</${open.kind}>`;
|
|
192
|
+
open = undefined;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (open) fail("Nested formatting needs explicit source review.");
|
|
196
|
+
const transparentSpan = /^<span\s*>$/i.test(token);
|
|
197
|
+
const style = token.match(/^<(b|strong|i|em|s|code)\s*>$/i);
|
|
198
|
+
const legacyStrike =
|
|
199
|
+
/^<span\s+style=(?:"text-decoration:\s*line-through;?"|'text-decoration:\s*line-through;?')\s*>$/i
|
|
200
|
+
.test(token);
|
|
201
|
+
const link = token.match(/^<a\s+href=(?:"([^"]*)"|'([^']*)')\s*>$/i);
|
|
202
|
+
if (!style && !legacyStrike && !link && !transparentSpan) {
|
|
203
|
+
fail(
|
|
204
|
+
"Supported text sync: plain text, attribute-free span, b/strong, i/em, s, code, web links and br. Review other formatting in QMD.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const tag = link
|
|
208
|
+
? "a"
|
|
209
|
+
: legacyStrike || transparentSpan
|
|
210
|
+
? "span"
|
|
211
|
+
: style![1].toLowerCase();
|
|
212
|
+
const kind = transparentSpan
|
|
213
|
+
? "span"
|
|
214
|
+
: link
|
|
215
|
+
? "a"
|
|
216
|
+
: legacyStrike || tag === "s"
|
|
217
|
+
? "s"
|
|
218
|
+
: tag === "code"
|
|
219
|
+
? "code"
|
|
220
|
+
: ["b", "strong"].includes(tag)
|
|
221
|
+
? "b"
|
|
222
|
+
: "i";
|
|
223
|
+
if (["b", "i", "s"].includes(kind) && /(?:\*\*|\*|~~)$/.test(markdown)) {
|
|
224
|
+
fail("Adjacent emphasis needs explicit source review.");
|
|
225
|
+
}
|
|
226
|
+
if (kind === "code" && /`$/.test(markdown)) {
|
|
227
|
+
fail("Adjacent code spans need explicit source review.");
|
|
228
|
+
}
|
|
229
|
+
open = {
|
|
230
|
+
tag,
|
|
231
|
+
kind,
|
|
232
|
+
text: "",
|
|
233
|
+
...(link ? { href: webLink(decodeEntities(link[1] ?? link[2])) } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (open) fail("HTML tags are unclosed.");
|
|
237
|
+
markdown = markdown.trim().replace(/(^|\n)([-+] )/g, "$1\\$2").replace(
|
|
238
|
+
/(^|\n)(\d+)([.)]) /g,
|
|
239
|
+
"$1$2\\$3 ",
|
|
240
|
+
);
|
|
241
|
+
if (!markdown) {
|
|
242
|
+
if (allowEmpty && !canonical.replace(/<br>/g, "").trim()) {
|
|
243
|
+
return { markdown: "", html: "" };
|
|
244
|
+
}
|
|
245
|
+
fail("An empty text field requires explicit deletion, not text sync.");
|
|
246
|
+
}
|
|
247
|
+
return { markdown, html: canonical.trim() };
|
|
248
|
+
}
|
|
249
|
+
export function inlineHtmlToMarkdown(html: string): string {
|
|
250
|
+
return convertInline(html).markdown;
|
|
251
|
+
}
|
|
252
|
+
export function canonicalInlineHtml(html: string): string {
|
|
253
|
+
return convertInline(html).html;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** An image may retain its source asset while its caption is explicitly cleared. */
|
|
257
|
+
export function imageCaptionHtmlToSource(html: string) {
|
|
258
|
+
return convertInline(html, true);
|
|
259
|
+
}
|