@brianmsm/quarto-bento 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +86 -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 +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -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 +759 -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 +2423 -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 +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -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 +433 -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,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
basename,
|
|
3
|
+
dirname,
|
|
4
|
+
isAbsolute,
|
|
5
|
+
relative,
|
|
6
|
+
resolve,
|
|
7
|
+
sep,
|
|
8
|
+
} from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import type { Ast } from "./types.ts";
|
|
11
|
+
import {
|
|
12
|
+
assertCompatibleProducerFilters,
|
|
13
|
+
attachProducerCapture,
|
|
14
|
+
prepareProducerCapture,
|
|
15
|
+
} from "./producer-capture.ts";
|
|
16
|
+
async function assertPrivateBuild(buildRoot: string): Promise<void> {
|
|
17
|
+
const pending = [buildRoot];
|
|
18
|
+
let entries = 0;
|
|
19
|
+
while (pending.length) {
|
|
20
|
+
const current = pending.pop()!;
|
|
21
|
+
if (++entries > 50_000) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"UNSAFE_BUILD_PATH: generated tree is too large to verify",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const info = await Deno.lstat(current);
|
|
27
|
+
if (info.isSymlink && current !== buildRoot) {
|
|
28
|
+
// Fontconfig can create aliases between cache files. They are safe only
|
|
29
|
+
// when the fully resolved target remains inside this generated tree.
|
|
30
|
+
let target: string;
|
|
31
|
+
try {
|
|
32
|
+
target = await Deno.realPath(current);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`UNSAFE_BUILD_PATH: unresolved alias ${current}`);
|
|
35
|
+
}
|
|
36
|
+
const location = relative(buildRoot, target);
|
|
37
|
+
if (
|
|
38
|
+
isAbsolute(location) || location === ".." ||
|
|
39
|
+
location.startsWith(`..${sep}`)
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(`UNSAFE_BUILD_PATH: external alias ${current}`);
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
info.isSymlink || !(info.isDirectory || info.isFile) ||
|
|
47
|
+
(info.isFile && (info.nlink === null || info.nlink > 1))
|
|
48
|
+
) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`UNSAFE_BUILD_PATH: Quarto output must contain only private files and real directories (${current})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (info.isDirectory) {
|
|
54
|
+
for await (const entry of Deno.readDir(current)) {
|
|
55
|
+
pending.push(resolve(current, entry.name));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function executeQuarto(
|
|
61
|
+
source: string,
|
|
62
|
+
buildDir: string,
|
|
63
|
+
): Promise<Ast> {
|
|
64
|
+
source = resolve(source);
|
|
65
|
+
buildDir = resolve(buildDir);
|
|
66
|
+
await Deno.mkdir(buildDir, { recursive: true });
|
|
67
|
+
await assertPrivateBuild(buildDir);
|
|
68
|
+
if (!(await Deno.lstat(buildDir)).isDirectory) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
"UNSAFE_BUILD_PATH: Quarto output must be a real directory",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const astPath = resolve(buildDir, "executed.json");
|
|
74
|
+
try {
|
|
75
|
+
await Deno.remove(astPath);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
|
78
|
+
}
|
|
79
|
+
await assertCompatibleProducerFilters(source);
|
|
80
|
+
const capture = await prepareProducerCapture(source);
|
|
81
|
+
try {
|
|
82
|
+
const filter = fileURLToPath(new URL("./capture.lua", import.meta.url));
|
|
83
|
+
const result = await new Deno.Command("quarto", {
|
|
84
|
+
cwd: dirname(source),
|
|
85
|
+
args: [
|
|
86
|
+
"render",
|
|
87
|
+
basename(source),
|
|
88
|
+
"--to",
|
|
89
|
+
"html",
|
|
90
|
+
"--lua-filter",
|
|
91
|
+
filter,
|
|
92
|
+
"-M",
|
|
93
|
+
"theme:none",
|
|
94
|
+
"--output",
|
|
95
|
+
"quarto-intermediate.html",
|
|
96
|
+
"--output-dir",
|
|
97
|
+
buildDir,
|
|
98
|
+
],
|
|
99
|
+
env: {
|
|
100
|
+
...capture.env,
|
|
101
|
+
QBENTO_AST: astPath,
|
|
102
|
+
QBENTO_INTERNAL_RENDER: "1",
|
|
103
|
+
XDG_CACHE_HOME: resolve(buildDir, "cache"),
|
|
104
|
+
},
|
|
105
|
+
stdout: "piped",
|
|
106
|
+
stderr: "piped",
|
|
107
|
+
}).output();
|
|
108
|
+
await Deno.writeFile(resolve(buildDir, "quarto.stdout.log"), result.stdout);
|
|
109
|
+
await Deno.writeFile(resolve(buildDir, "quarto.stderr.log"), result.stderr);
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`QUARTO_FAILED: ${new TextDecoder().decode(result.stderr)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const ast = JSON.parse(await Deno.readTextFile(astPath));
|
|
116
|
+
if (!Array.isArray(ast.blocks)) {
|
|
117
|
+
throw new Error("INVALID_AST: Quarto did not emit blocks");
|
|
118
|
+
}
|
|
119
|
+
return attachProducerCapture(
|
|
120
|
+
ast,
|
|
121
|
+
capture.manifest,
|
|
122
|
+
await capture.emitted(),
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
await capture.cleanup();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { sha256 } from "../state/store.ts";
|
|
3
|
+
import {
|
|
4
|
+
type DesignDefaults,
|
|
5
|
+
DesignError,
|
|
6
|
+
type DesignTheme,
|
|
7
|
+
type LayoutDefinition,
|
|
8
|
+
validateDesignDefaults,
|
|
9
|
+
} from "./design.ts";
|
|
10
|
+
|
|
11
|
+
export interface DesignRegistry {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
layouts?: Record<string, LayoutDefinition>;
|
|
14
|
+
themes?: Record<string, DesignTheme>;
|
|
15
|
+
activeTheme?: string;
|
|
16
|
+
}
|
|
17
|
+
const names = /^[a-z][a-z0-9-]{0,63}$/;
|
|
18
|
+
export const MAX_DESIGN_REGISTRY_BYTES = 262144;
|
|
19
|
+
const maximumBytes = MAX_DESIGN_REGISTRY_BYTES;
|
|
20
|
+
function object(value: unknown): asserts value is Record<string, unknown> {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
22
|
+
throw new DesignError("INVALID_DESIGN_REGISTRY", "Expected a JSON object.");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Versioned JSON data, never code, remote dependencies or Quarto configuration. */
|
|
26
|
+
export function validateDesignRegistry(value: unknown): DesignRegistry {
|
|
27
|
+
object(value);
|
|
28
|
+
if (
|
|
29
|
+
value.schemaVersion !== 1 ||
|
|
30
|
+
Object.keys(value).some((key) =>
|
|
31
|
+
!["schemaVersion", "layouts", "themes", "activeTheme"].includes(key)
|
|
32
|
+
)
|
|
33
|
+
) {
|
|
34
|
+
throw new DesignError(
|
|
35
|
+
"INVALID_DESIGN_REGISTRY",
|
|
36
|
+
"Expected schemaVersion 1 and only layouts/themes/activeTheme.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const result: DesignRegistry = { schemaVersion: 1 };
|
|
40
|
+
if (value.layouts !== undefined) {
|
|
41
|
+
object(value.layouts);
|
|
42
|
+
if ("two-column" in value.layouts) {
|
|
43
|
+
throw new DesignError(
|
|
44
|
+
"RESERVED_LAYOUT",
|
|
45
|
+
"two-column is built in; choose a distinct custom name.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
result.layouts = validateDesignDefaults({
|
|
49
|
+
layouts: value.layouts as Record<string, LayoutDefinition>,
|
|
50
|
+
}).layouts;
|
|
51
|
+
}
|
|
52
|
+
if (value.themes !== undefined) {
|
|
53
|
+
object(value.themes);
|
|
54
|
+
if (Object.keys(value.themes).length > 32) {
|
|
55
|
+
throw new DesignError(
|
|
56
|
+
"INVALID_DESIGN_REGISTRY",
|
|
57
|
+
"At most 32 named themes.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
result.themes = Object.create(null);
|
|
61
|
+
for (const [name, theme] of Object.entries(value.themes)) {
|
|
62
|
+
object(theme);
|
|
63
|
+
if (!names.test(name)) {
|
|
64
|
+
throw new DesignError(
|
|
65
|
+
"INVALID_DESIGN_REGISTRY",
|
|
66
|
+
`Unsafe theme name: ${name}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
result.themes![name] = validateDesignDefaults({
|
|
70
|
+
theme: theme as unknown as DesignTheme,
|
|
71
|
+
}).theme!;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (value.activeTheme !== undefined) {
|
|
75
|
+
if (
|
|
76
|
+
typeof value.activeTheme !== "string" || !names.test(value.activeTheme) ||
|
|
77
|
+
!Object.hasOwn(result.themes ?? {}, value.activeTheme)
|
|
78
|
+
) {
|
|
79
|
+
throw new DesignError(
|
|
80
|
+
"UNKNOWN_THEME",
|
|
81
|
+
"activeTheme must name a declared theme.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
result.activeTheme = value.activeTheme;
|
|
85
|
+
} else if (Object.keys(result.themes ?? {}).length) {
|
|
86
|
+
throw new DesignError(
|
|
87
|
+
"THEME_SELECTION_REQUIRED",
|
|
88
|
+
"Select activeTheme explicitly; JSON key order never selects a theme.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
export function registryDefaults(registry: DesignRegistry): DesignDefaults {
|
|
94
|
+
const checked = validateDesignRegistry(registry);
|
|
95
|
+
return {
|
|
96
|
+
...(checked.layouts ? { layouts: checked.layouts } : {}),
|
|
97
|
+
...(checked.activeTheme
|
|
98
|
+
? { theme: checked.themes![checked.activeTheme] }
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async function designRegistryReady(
|
|
103
|
+
directory: string,
|
|
104
|
+
transactionToken?: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const stateDir = resolve(directory, ".qbento");
|
|
107
|
+
try {
|
|
108
|
+
const info = await Deno.lstat(stateDir);
|
|
109
|
+
if (info.isSymlink || !info.isDirectory) {
|
|
110
|
+
throw new DesignError(
|
|
111
|
+
"UNSAFE_DESIGN_REGISTRY",
|
|
112
|
+
".qbento must be a real directory.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof Deno.errors.NotFound) return;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const journal = resolve(stateDir, "design-save.json");
|
|
120
|
+
try {
|
|
121
|
+
const marker = await Deno.lstat(journal);
|
|
122
|
+
if (
|
|
123
|
+
!transactionToken || marker.isSymlink || !marker.isFile ||
|
|
124
|
+
marker.size > 8192 || marker.nlink !== null && marker.nlink > 1
|
|
125
|
+
) {
|
|
126
|
+
throw new DesignError(
|
|
127
|
+
"DESIGN_REGISTRY_RECOVERY_REQUIRED",
|
|
128
|
+
"An interrupted registry save is retained at .qbento/design-save.json. Inspect its history receipt and restore or finish the saved registry before loading or recalculating.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const receipt = JSON.parse(await Deno.readTextFile(journal));
|
|
132
|
+
if (receipt.token !== transactionToken) {
|
|
133
|
+
throw new DesignError(
|
|
134
|
+
"DESIGN_REGISTRY_RECOVERY_REQUIRED",
|
|
135
|
+
"Another registry save requires recovery before loading the registry.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Refuse an unresolved save before other raw registry readers infer absence. */
|
|
143
|
+
export async function assertDesignRegistryReady(
|
|
144
|
+
directory: string,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
await designRegistryReady(directory);
|
|
147
|
+
}
|
|
148
|
+
async function registryBytes(
|
|
149
|
+
directory: string,
|
|
150
|
+
transactionToken?: string,
|
|
151
|
+
): Promise<Uint8Array | null> {
|
|
152
|
+
await designRegistryReady(directory, transactionToken);
|
|
153
|
+
const stateDir = resolve(directory, ".qbento");
|
|
154
|
+
const path = resolve(stateDir, "design.json");
|
|
155
|
+
try {
|
|
156
|
+
const info = await Deno.lstat(path);
|
|
157
|
+
if (
|
|
158
|
+
info.isSymlink || !info.isFile || info.nlink !== null && info.nlink > 1
|
|
159
|
+
) {
|
|
160
|
+
throw new DesignError(
|
|
161
|
+
"UNSAFE_DESIGN_REGISTRY",
|
|
162
|
+
"design.json must be a regular unlinked file.",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (info.size > maximumBytes) {
|
|
166
|
+
throw new DesignError(
|
|
167
|
+
"DESIGN_REGISTRY_TOO_LARGE",
|
|
168
|
+
"design.json exceeds 256 KiB.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
// Fixed filename and verified directory/file prevent path or URL selection.
|
|
172
|
+
const file = await Deno.open(path, { read: true });
|
|
173
|
+
try {
|
|
174
|
+
const bytes = new Uint8Array(maximumBytes + 1);
|
|
175
|
+
let size = 0;
|
|
176
|
+
for (;;) {
|
|
177
|
+
const read = await file.read(bytes.subarray(size));
|
|
178
|
+
if (read === null) break;
|
|
179
|
+
size += read;
|
|
180
|
+
if (size > maximumBytes) {
|
|
181
|
+
throw new DesignError(
|
|
182
|
+
"DESIGN_REGISTRY_TOO_LARGE",
|
|
183
|
+
"design.json exceeds 256 KiB.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return bytes.slice(0, size);
|
|
188
|
+
} finally {
|
|
189
|
+
file.close();
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof Deno.errors.NotFound) return null;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Preserve the complete validated registry and its exact-byte freshness token. */
|
|
197
|
+
export async function readDesignRegistry(
|
|
198
|
+
directory: string,
|
|
199
|
+
): Promise<{ registry: DesignRegistry; hash: string | null }> {
|
|
200
|
+
const bytes = await registryBytes(directory);
|
|
201
|
+
if (!bytes) return { registry: { schemaVersion: 1 }, hash: null };
|
|
202
|
+
let value: unknown;
|
|
203
|
+
try {
|
|
204
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
205
|
+
} catch {
|
|
206
|
+
throw new DesignError(
|
|
207
|
+
"INVALID_DESIGN_REGISTRY",
|
|
208
|
+
"design.json must contain valid UTF-8 JSON.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { registry: validateDesignRegistry(value), hash: await sha256(bytes) };
|
|
212
|
+
}
|
|
213
|
+
export async function loadDesignRegistry(
|
|
214
|
+
directory: string,
|
|
215
|
+
): Promise<{ defaults: DesignDefaults; hash: string | null }> {
|
|
216
|
+
const snapshot = await readDesignRegistry(directory);
|
|
217
|
+
return { defaults: registryDefaults(snapshot.registry), hash: snapshot.hash };
|
|
218
|
+
}
|
|
219
|
+
/** CAS includes both absence and exact bytes; native sparse overrides are independent. */
|
|
220
|
+
export async function assertDesignRegistryUnchanged(
|
|
221
|
+
directory: string,
|
|
222
|
+
expected: string | null,
|
|
223
|
+
transactionToken?: string,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const bytes = await registryBytes(directory, transactionToken);
|
|
226
|
+
const actual = bytes === null ? null : await sha256(bytes);
|
|
227
|
+
if (actual !== expected) {
|
|
228
|
+
throw new DesignError(
|
|
229
|
+
"DESIGN_REGISTRY_CHANGED",
|
|
230
|
+
"design.json changed during the checked operation; retry from the current registry.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Bounded resource dependency inspection. No DOM, fetch, mutation or SVG repair. */
|
|
2
|
+
export class ResourceDependencyError extends Error {}
|
|
3
|
+
const maximum = 8 * 1024 * 1024;
|
|
4
|
+
// Browser schemes ignore ASCII whitespace/control characters.
|
|
5
|
+
// deno-lint-ignore no-control-regex
|
|
6
|
+
const controls = /[\u0000-\u0020\u007f]/g;
|
|
7
|
+
function fail(reason: string): never {
|
|
8
|
+
throw new ResourceDependencyError(
|
|
9
|
+
`${reason}; embed the resource in the SVG or import a self-contained local snapshot before saving`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
function entities(value: string, literalUnknown = false): string {
|
|
13
|
+
const named: Record<string, string> = {
|
|
14
|
+
amp: "&",
|
|
15
|
+
lt: "<",
|
|
16
|
+
gt: ">",
|
|
17
|
+
quot: '"',
|
|
18
|
+
apos: "'",
|
|
19
|
+
colon: ":",
|
|
20
|
+
sol: "/",
|
|
21
|
+
bsol: "\\",
|
|
22
|
+
Tab: "\t",
|
|
23
|
+
NewLine: "\n",
|
|
24
|
+
num: "#",
|
|
25
|
+
lpar: "(",
|
|
26
|
+
rpar: ")",
|
|
27
|
+
period: ".",
|
|
28
|
+
comma: ",",
|
|
29
|
+
semi: ";",
|
|
30
|
+
equals: "=",
|
|
31
|
+
percnt: "%",
|
|
32
|
+
plus: "+",
|
|
33
|
+
quest: "?",
|
|
34
|
+
commat: "@",
|
|
35
|
+
};
|
|
36
|
+
return value.replace(
|
|
37
|
+
/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);?/gi,
|
|
38
|
+
(whole, name: string) => {
|
|
39
|
+
if (name.startsWith("#")) {
|
|
40
|
+
const n = name[1].toLowerCase() === "x"
|
|
41
|
+
? parseInt(name.slice(2), 16)
|
|
42
|
+
: Number(name.slice(1));
|
|
43
|
+
if (!Number.isSafeInteger(n) || n <= 0 || n > 0x10ffff) {
|
|
44
|
+
fail(
|
|
45
|
+
"Unsupported reference entity",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return String.fromCodePoint(n);
|
|
49
|
+
}
|
|
50
|
+
if (!Object.hasOwn(named, name)) {
|
|
51
|
+
if (literalUnknown) return whole;
|
|
52
|
+
fail(
|
|
53
|
+
`Unsupported reference entity ${whole}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return named[name];
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
function cssEscapes(value: string): string {
|
|
61
|
+
return value.replace(
|
|
62
|
+
/\\(?:([0-9a-f]{1,6})(?:\r\n|[\t\n\r\f ])?|([\s\S]))/gi,
|
|
63
|
+
(_m, hex: string, char: string) => {
|
|
64
|
+
if (hex) {
|
|
65
|
+
const n = parseInt(hex, 16);
|
|
66
|
+
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "\ufffd";
|
|
67
|
+
}
|
|
68
|
+
return /[\n\r\f]/.test(char) ? "" : char;
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function dataSvg(value: string): string | null {
|
|
73
|
+
if (!/^data:image\/svg\+xml(?:[;,])/i.test(value)) return null;
|
|
74
|
+
const comma = value.indexOf(",");
|
|
75
|
+
if (comma < 0) fail("Malformed embedded SVG data");
|
|
76
|
+
try {
|
|
77
|
+
const header = value.slice(0, comma), payload = value.slice(comma + 1);
|
|
78
|
+
return /;base64$/i.test(header)
|
|
79
|
+
? new TextDecoder("utf-8", { fatal: true }).decode(
|
|
80
|
+
Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)),
|
|
81
|
+
)
|
|
82
|
+
: decodeURIComponent(payload);
|
|
83
|
+
} catch {
|
|
84
|
+
fail("Malformed embedded SVG data");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function reference(value: string, depth: number, navigation = false): void {
|
|
88
|
+
const target = value.trim();
|
|
89
|
+
if (!target || target.startsWith("#")) return;
|
|
90
|
+
const normalized = target.replace(controls, "");
|
|
91
|
+
if (navigation && /^https?:\/\/[^\s<>"']+$/i.test(normalized)) return;
|
|
92
|
+
const svg = dataSvg(normalized);
|
|
93
|
+
if (svg !== null) {
|
|
94
|
+
assertOfflineSvg(svg, depth + 1);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
/^data:(?:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon)|font\/(?:woff2?|ttf|otf)|application\/(?:font-woff|x-font-ttf|x-font-opentype))(?:;[^,]*)?,/i
|
|
99
|
+
.test(normalized)
|
|
100
|
+
) return;
|
|
101
|
+
fail("External, transient or unresolved SVG resource reference");
|
|
102
|
+
}
|
|
103
|
+
/** Inspect CSS resources before the pinned renderer would strip declarations. */
|
|
104
|
+
export function assertOfflineSvgCss(css: string, depth = 0): void {
|
|
105
|
+
if (css.length > maximum) fail("SVG CSS exceeds the inspection bound");
|
|
106
|
+
function stringEnd(start: number): number {
|
|
107
|
+
const quote = css[start];
|
|
108
|
+
let i = start + 1;
|
|
109
|
+
for (; i < css.length; i++) {
|
|
110
|
+
if (css[i] === "\\") {
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (css[i] === quote) return i;
|
|
115
|
+
}
|
|
116
|
+
fail("Unclosed SVG CSS string");
|
|
117
|
+
}
|
|
118
|
+
function skipTrivia(start: number): number {
|
|
119
|
+
let i = start;
|
|
120
|
+
while (i < css.length) {
|
|
121
|
+
if (/\s/.test(css[i])) {
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (css.startsWith("/*", i)) {
|
|
126
|
+
const end = css.indexOf("*/", i + 2);
|
|
127
|
+
if (end < 0) return css.length;
|
|
128
|
+
i = end + 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
return i;
|
|
134
|
+
}
|
|
135
|
+
const identifier =
|
|
136
|
+
/^(?:[-\w]|\\(?:[0-9a-f]{1,6}(?:\r\n|[\t\n\r\f ])?|[\s\S]))+/i;
|
|
137
|
+
let i = 0;
|
|
138
|
+
while (i < css.length) {
|
|
139
|
+
i = skipTrivia(i);
|
|
140
|
+
if (i >= css.length) break;
|
|
141
|
+
if (css[i] === '"' || css[i] === "'") {
|
|
142
|
+
i = stringEnd(i) + 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const at = css[i] === "@";
|
|
146
|
+
if (at) i++;
|
|
147
|
+
const match = identifier.exec(css.slice(i));
|
|
148
|
+
if (!match) {
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const name = cssEscapes(match[0]).toLowerCase();
|
|
153
|
+
i += match[0].length;
|
|
154
|
+
if (at && name === "import") fail("SVG CSS imports another stylesheet");
|
|
155
|
+
const open = skipTrivia(i);
|
|
156
|
+
if (css[open] !== "(") continue;
|
|
157
|
+
if (
|
|
158
|
+
[
|
|
159
|
+
"image-set",
|
|
160
|
+
"-webkit-image-set",
|
|
161
|
+
"image",
|
|
162
|
+
"cross-fade",
|
|
163
|
+
"-webkit-cross-fade",
|
|
164
|
+
"paint",
|
|
165
|
+
].includes(name)
|
|
166
|
+
) {
|
|
167
|
+
fail(
|
|
168
|
+
"SVG CSS image/resource functions require a self-contained snapshot",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (name !== "url") continue;
|
|
172
|
+
const start = skipTrivia(open + 1);
|
|
173
|
+
let end: number;
|
|
174
|
+
if (css[start] === '"' || css[start] === "'") {
|
|
175
|
+
end = stringEnd(start);
|
|
176
|
+
const target = cssEscapes(css.slice(start + 1, end));
|
|
177
|
+
i = skipTrivia(end + 1);
|
|
178
|
+
if (css[i] !== ")") fail("Malformed SVG CSS resource reference");
|
|
179
|
+
reference(target, depth);
|
|
180
|
+
} else {
|
|
181
|
+
end = start;
|
|
182
|
+
while (end < css.length) {
|
|
183
|
+
if (css[end] === "\\") {
|
|
184
|
+
end += 2;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (css[end] === ")") break;
|
|
188
|
+
end++;
|
|
189
|
+
}
|
|
190
|
+
if (end === css.length) fail("Unclosed SVG CSS resource reference");
|
|
191
|
+
reference(cssEscapes(css.slice(start, end)), depth);
|
|
192
|
+
i = end;
|
|
193
|
+
}
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Both inline SVG and asset-backed SVG use the pinned HTML SVG renderer. */
|
|
198
|
+
export function assertOfflineSvg(markup: string, depth = 0): void {
|
|
199
|
+
if (markup.length > maximum || depth > 4) {
|
|
200
|
+
fail("SVG exceeds the inspection size/depth bound");
|
|
201
|
+
}
|
|
202
|
+
if (!markup.trim()) return; // Native empty SVG/embed placeholders are retained.
|
|
203
|
+
if (/^\s*data:/i.test(markup)) {
|
|
204
|
+
const decoded = dataSvg(markup.trim());
|
|
205
|
+
if (decoded === null) {
|
|
206
|
+
fail("SVG view requires inline SVG markup, not a resource URL");
|
|
207
|
+
}
|
|
208
|
+
// svg.markup/embed.view are markup, not data URI inputs in pinned render.ts.
|
|
209
|
+
fail("SVG view requires decoded inline SVG markup, not a data URI");
|
|
210
|
+
}
|
|
211
|
+
if (!/<\s*svg(?:\s|\/?>)/i.test(markup)) {
|
|
212
|
+
fail("SVG view requires inline SVG markup");
|
|
213
|
+
}
|
|
214
|
+
// Entity declarations and stylesheet instructions cannot be inspected as data.
|
|
215
|
+
const declarations = markup.replace(
|
|
216
|
+
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>/g,
|
|
217
|
+
"",
|
|
218
|
+
);
|
|
219
|
+
if (
|
|
220
|
+
/<!\s*ENTITY\b|<!\s*DOCTYPE[^>]*\[|<\?xml-stylesheet\b/i.test(declarations)
|
|
221
|
+
) {
|
|
222
|
+
fail("SVG external declarations are unsupported");
|
|
223
|
+
}
|
|
224
|
+
const tag =
|
|
225
|
+
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[^>]*\?>|<\/?([a-z][\w:.-]*)/gi;
|
|
226
|
+
let match: RegExpExecArray | null;
|
|
227
|
+
while ((match = tag.exec(markup))) {
|
|
228
|
+
if (!match[1] || match[0].startsWith("</")) continue;
|
|
229
|
+
const name = match[1].toLowerCase();
|
|
230
|
+
let i = tag.lastIndex;
|
|
231
|
+
for (;;) {
|
|
232
|
+
while (i < markup.length && /\s/.test(markup[i])) i++;
|
|
233
|
+
if (markup[i] === "/" && markup[i + 1] === ">") {
|
|
234
|
+
i += 2;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (markup[i] === ">") {
|
|
238
|
+
i++;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
const attr = /^[^\s/>=]+/.exec(markup.slice(i));
|
|
242
|
+
if (!attr) fail("Malformed SVG attribute boundary");
|
|
243
|
+
const key = attr[0].toLowerCase();
|
|
244
|
+
i += attr[0].length;
|
|
245
|
+
while (i < markup.length && /\s/.test(markup[i])) i++;
|
|
246
|
+
if (markup[i] !== "=") continue;
|
|
247
|
+
i++;
|
|
248
|
+
while (i < markup.length && /\s/.test(markup[i])) i++;
|
|
249
|
+
const quote = markup[i] === '"' || markup[i] === "'" ? markup[i++] : "";
|
|
250
|
+
const start = i;
|
|
251
|
+
while (
|
|
252
|
+
i < markup.length &&
|
|
253
|
+
(quote ? markup[i] !== quote : !/[\s>]/.test(markup[i]))
|
|
254
|
+
) i++;
|
|
255
|
+
if (i === markup.length) fail("Unclosed SVG resource attribute");
|
|
256
|
+
const raw = markup.slice(start, i);
|
|
257
|
+
if (quote) i++;
|
|
258
|
+
if (
|
|
259
|
+
["href", "xlink:href", "src", "poster", "data", "srcset"].includes(key)
|
|
260
|
+
) {
|
|
261
|
+
reference(
|
|
262
|
+
entities(raw, true),
|
|
263
|
+
depth,
|
|
264
|
+
name === "a" && /href$/.test(key),
|
|
265
|
+
);
|
|
266
|
+
} else if (key === "xml:base" && raw.trim()) {
|
|
267
|
+
fail("SVG base changes resource resolution");
|
|
268
|
+
} else if (
|
|
269
|
+
key === "style" || /(?:url\s*\(|\\|@)/i.test(entities(raw, true))
|
|
270
|
+
) {
|
|
271
|
+
assertOfflineSvgCss(entities(raw, true), depth);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (name === "style") {
|
|
275
|
+
const close = /<\/style\s*>/gi;
|
|
276
|
+
close.lastIndex = i;
|
|
277
|
+
const end = close.exec(markup);
|
|
278
|
+
if (!end) fail("Unclosed SVG stylesheet");
|
|
279
|
+
const css = markup.slice(i, end.index).replace(
|
|
280
|
+
/<!\[CDATA\[([\s\S]*?)\]\]>/g,
|
|
281
|
+
"$1",
|
|
282
|
+
);
|
|
283
|
+
assertOfflineSvgCss(entities(css), depth);
|
|
284
|
+
}
|
|
285
|
+
tag.lastIndex = i;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Raster image fields can also contain SVG data; inspect those nested bytes. */
|
|
289
|
+
export function assertOfflineSvgData(uri: string): void {
|
|
290
|
+
const svg = dataSvg(uri);
|
|
291
|
+
if (svg !== null) assertOfflineSvg(svg, 1);
|
|
292
|
+
}
|