@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,572 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertOfflineSvg,
|
|
3
|
+
assertOfflineSvgCss,
|
|
4
|
+
assertOfflineSvgData,
|
|
5
|
+
ResourceDependencyError,
|
|
6
|
+
} from "./resources.ts";
|
|
7
|
+
/** Pinned Bento shape checks, without coercion, mutation or network access. */
|
|
8
|
+
import schemaJson from "../../vendor/bento/slides.schema.json" with {
|
|
9
|
+
type: "json",
|
|
10
|
+
};
|
|
11
|
+
import { assertJson } from "../state/json.ts";
|
|
12
|
+
|
|
13
|
+
type RecordValue = Record<string, unknown>;
|
|
14
|
+
interface Schema {
|
|
15
|
+
$ref?: string;
|
|
16
|
+
$defs?: Record<string, Schema>;
|
|
17
|
+
type?: string;
|
|
18
|
+
const?: unknown;
|
|
19
|
+
enum?: unknown[];
|
|
20
|
+
required?: string[];
|
|
21
|
+
properties?: Record<string, Schema>;
|
|
22
|
+
additionalProperties?: boolean | Schema;
|
|
23
|
+
items?: Schema;
|
|
24
|
+
anyOf?: Schema[];
|
|
25
|
+
oneOf?: Schema[];
|
|
26
|
+
minimum?: number;
|
|
27
|
+
maximum?: number;
|
|
28
|
+
maxLength?: number;
|
|
29
|
+
pattern?: string;
|
|
30
|
+
minItems?: number;
|
|
31
|
+
maxItems?: number;
|
|
32
|
+
maxProperties?: number;
|
|
33
|
+
}
|
|
34
|
+
const schema = schemaJson as Schema;
|
|
35
|
+
export const BENTO_CONTRACT_REVISION =
|
|
36
|
+
"3d6f7f1fcdf2308e875345ce4f1e0ec0820c914b";
|
|
37
|
+
export interface ContractFinding {
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
export class BentoContractError extends Error {
|
|
42
|
+
readonly code = "BENTO_CONTRACT";
|
|
43
|
+
constructor(readonly details: ContractFinding[]) {
|
|
44
|
+
super(details.map((f) => `${f.path || "/"}: ${f.message}`).join("; "));
|
|
45
|
+
this.name = "BentoContractError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const isObject = (value: unknown): value is RecordValue =>
|
|
49
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
const pointer = (parent: string, key: string | number) =>
|
|
51
|
+
parent + "/" + String(key).replaceAll("~", "~0").replaceAll("/", "~1");
|
|
52
|
+
function reject(path: string, message: string): never {
|
|
53
|
+
throw new BentoContractError([{ path, message }]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Implements only the keywords present in the checked-in upstream schema. */
|
|
57
|
+
function check(value: unknown, rule: Schema, path: string) {
|
|
58
|
+
if (rule.$ref) {
|
|
59
|
+
if (!rule.$ref.startsWith("#/$defs/")) {
|
|
60
|
+
reject(path, "Unsupported schema reference");
|
|
61
|
+
}
|
|
62
|
+
const referenced = schema.$defs?.[rule.$ref.slice(8)];
|
|
63
|
+
if (!referenced) reject(path, "Missing pinned schema definition");
|
|
64
|
+
check(value, referenced, path);
|
|
65
|
+
}
|
|
66
|
+
for (
|
|
67
|
+
const [mode, alternatives] of [["anyOf", rule.anyOf], [
|
|
68
|
+
"oneOf",
|
|
69
|
+
rule.oneOf,
|
|
70
|
+
]] as const
|
|
71
|
+
) {
|
|
72
|
+
if (!alternatives) continue;
|
|
73
|
+
let successes = 0;
|
|
74
|
+
for (const candidate of alternatives) {
|
|
75
|
+
try {
|
|
76
|
+
check(value, candidate, path);
|
|
77
|
+
successes++;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof BentoContractError)) throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!successes || (mode === "oneOf" && successes !== 1)) {
|
|
83
|
+
// Element union: report the matching type's property instead of nine alternatives.
|
|
84
|
+
if (isObject(value) && typeof value.type === "string" && rule.oneOf) {
|
|
85
|
+
const selected = schema.$defs?.[value.type];
|
|
86
|
+
if (
|
|
87
|
+
selected &&
|
|
88
|
+
alternatives.some((r) => r.$ref === `#/$defs/${value.type}`)
|
|
89
|
+
) {
|
|
90
|
+
check(value, selected, path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
reject(path, `Value does not match ${mode} in the pinned Bento schema`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (Object.hasOwn(rule, "const") && value !== rule.const) {
|
|
97
|
+
reject(path, `Expected ${JSON.stringify(rule.const)}`);
|
|
98
|
+
}
|
|
99
|
+
if (rule.enum && !rule.enum.includes(value)) {
|
|
100
|
+
reject(path, "Unsupported enum value");
|
|
101
|
+
}
|
|
102
|
+
if (rule.type) {
|
|
103
|
+
const matches = rule.type === "object"
|
|
104
|
+
? isObject(value)
|
|
105
|
+
: rule.type === "array"
|
|
106
|
+
? Array.isArray(value)
|
|
107
|
+
: rule.type === "integer"
|
|
108
|
+
? typeof value === "number" && Number.isInteger(value)
|
|
109
|
+
: rule.type === "string"
|
|
110
|
+
? typeof value === "string"
|
|
111
|
+
: rule.type === "number"
|
|
112
|
+
? typeof value === "number"
|
|
113
|
+
: rule.type === "boolean"
|
|
114
|
+
? typeof value === "boolean"
|
|
115
|
+
: false;
|
|
116
|
+
if (!matches) {
|
|
117
|
+
reject(path, `Expected ${rule.type}; values are never coerced`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === "number") {
|
|
121
|
+
if (!Number.isFinite(value)) reject(path, "Expected a finite number");
|
|
122
|
+
if (rule.minimum !== undefined && value < rule.minimum) {
|
|
123
|
+
reject(path, `Below minimum ${rule.minimum}`);
|
|
124
|
+
}
|
|
125
|
+
if (rule.maximum !== undefined && value > rule.maximum) {
|
|
126
|
+
reject(path, `Above maximum ${rule.maximum}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
// The pinned gate uses JS length, not Unicode scalar count.
|
|
131
|
+
if (rule.maxLength !== undefined && value.length > rule.maxLength) {
|
|
132
|
+
reject(path, `Exceeds ${rule.maxLength} characters`);
|
|
133
|
+
}
|
|
134
|
+
if (rule.pattern && !new RegExp(rule.pattern).test(value)) {
|
|
135
|
+
reject(path, "Does not match the pinned property pattern");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(value)) {
|
|
139
|
+
if (rule.minItems !== undefined && value.length < rule.minItems) {
|
|
140
|
+
reject(path, "Too few items");
|
|
141
|
+
}
|
|
142
|
+
if (rule.maxItems !== undefined && value.length > rule.maxItems) {
|
|
143
|
+
reject(path, "Too many items");
|
|
144
|
+
}
|
|
145
|
+
if (rule.items) {
|
|
146
|
+
value.forEach((item, i) => check(item, rule.items!, pointer(path, i)));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (isObject(value)) {
|
|
150
|
+
if (
|
|
151
|
+
rule.maxProperties !== undefined &&
|
|
152
|
+
Object.keys(value).length > rule.maxProperties
|
|
153
|
+
) reject(path, "Too many properties");
|
|
154
|
+
for (const key of rule.required ?? []) {
|
|
155
|
+
if (!Object.hasOwn(value, key)) {
|
|
156
|
+
reject(pointer(path, key), "Required property missing");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const [key, child] of Object.entries(value)) {
|
|
160
|
+
const property = rule.properties?.[key];
|
|
161
|
+
if (property) check(child, property, pointer(path, key));
|
|
162
|
+
else if (rule.additionalProperties === false) {
|
|
163
|
+
reject(pointer(path, key), "Unknown property at the pinned revision");
|
|
164
|
+
} else if (isObject(rule.additionalProperties)) {
|
|
165
|
+
check(child, rule.additionalProperties as Schema, pointer(path, key));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cssBreakout = /["'<>{};]/;
|
|
172
|
+
const colorCharacters = /^[#a-zA-Z0-9(),.%\s/-]+$/;
|
|
173
|
+
const colorTricks = /url\s*\(|expression|@|\\/i;
|
|
174
|
+
const localPaint = /^url\(\s*(?:"#[\w.:-]+"|'#[\w.:-]+'|#[\w.:-]+)\s*\)$/;
|
|
175
|
+
const pathData = /^[MmLlHhVvCcSsQqTtAaZz0-9\s,.+\-eE]*$/;
|
|
176
|
+
const webUrl = /^https?:\/\/[^\s\x22\x27<>]+$/i;
|
|
177
|
+
const slots = new Set([
|
|
178
|
+
"bg1",
|
|
179
|
+
"tx1",
|
|
180
|
+
"bg2",
|
|
181
|
+
"tx2",
|
|
182
|
+
"accent1",
|
|
183
|
+
"accent2",
|
|
184
|
+
"accent3",
|
|
185
|
+
"accent4",
|
|
186
|
+
"accent5",
|
|
187
|
+
"accent6",
|
|
188
|
+
"hlink",
|
|
189
|
+
"folHlink",
|
|
190
|
+
]);
|
|
191
|
+
function css(value: unknown, path: string) {
|
|
192
|
+
if (typeof value !== "string" || cssBreakout.test(value)) {
|
|
193
|
+
reject(path, "Bento would drop this CSS/identity value");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function color(value: unknown, path: string, paint = false, empty = false) {
|
|
197
|
+
if (typeof value !== "string") reject(path, "Expected a colour string");
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
if (empty && value === "") return;
|
|
200
|
+
if (paint && localPaint.test(trimmed)) return;
|
|
201
|
+
if (!trimmed || !colorCharacters.test(trimmed) || colorTricks.test(trimmed)) {
|
|
202
|
+
reject(path, "Bento would drop or clear this colour/paint");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function boundedData(value: unknown, path: string) {
|
|
206
|
+
let nodes = 20_000;
|
|
207
|
+
const visit = (item: unknown, at: string, depth: number) => {
|
|
208
|
+
if (--nodes < 0 || depth < 0) {
|
|
209
|
+
reject(at, "Bento would drop this data object (20,000 nodes / depth 12)");
|
|
210
|
+
}
|
|
211
|
+
if (typeof item === "string" && item.length > 65_536) {
|
|
212
|
+
reject(at, "Bento would drop this data object (string exceeds 64 KiB)");
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(item)) {
|
|
215
|
+
item.forEach((child, i) => visit(child, pointer(at, i), depth - 1));
|
|
216
|
+
} else if (isObject(item)) {
|
|
217
|
+
for (const [key, child] of Object.entries(item)) {
|
|
218
|
+
if (key.length > 200) {
|
|
219
|
+
reject(pointer(at, key), "Data key exceeds 200 characters");
|
|
220
|
+
}
|
|
221
|
+
visit(child, pointer(at, key), depth - 1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
visit(value, path, 12);
|
|
226
|
+
}
|
|
227
|
+
function chartColors(value: unknown, path: string): void {
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
value.forEach((item, index) => chartColors(item, pointer(path, index)));
|
|
230
|
+
} else if (isObject(value)) {
|
|
231
|
+
for (const [key, item] of Object.entries(value)) {
|
|
232
|
+
const at = pointer(path, key);
|
|
233
|
+
if (key === "color" && typeof item === "string") color(item, at);
|
|
234
|
+
else if (key === "color" && Array.isArray(item)) {
|
|
235
|
+
item.forEach((entry, index) => {
|
|
236
|
+
const entryPath = pointer(at, index);
|
|
237
|
+
if (typeof entry === "string") color(entry, entryPath);
|
|
238
|
+
else chartColors(entry, entryPath);
|
|
239
|
+
});
|
|
240
|
+
} else chartColors(item, at);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** Descriptive schema fields omit a few checks implemented by untrusted.ts. */
|
|
245
|
+
function supplementary(value: unknown, rule: Schema, path: string) {
|
|
246
|
+
if (rule.$ref) {
|
|
247
|
+
const reference = schema.$defs![rule.$ref.slice(8)];
|
|
248
|
+
if (rule.$ref === "#/$defs/prop-option") {
|
|
249
|
+
boundedData(value, path);
|
|
250
|
+
chartColors(value, path);
|
|
251
|
+
}
|
|
252
|
+
if (rule.$ref === "#/$defs/prop-doc") {
|
|
253
|
+
if (typeof value === "string") css(value, path);
|
|
254
|
+
else if (isObject(value)) {
|
|
255
|
+
boundedData(value, path);
|
|
256
|
+
for (const field of ["collab", "docId"]) {
|
|
257
|
+
if (Object.hasOwn(value, field)) {
|
|
258
|
+
reject(
|
|
259
|
+
pointer(path, field),
|
|
260
|
+
"Bento strips embedded document identity/credentials; resolve explicitly",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (rule.$ref === "#/$defs/prop-themeRefs" && isObject(value)) {
|
|
267
|
+
if (!Object.keys(value).length) {
|
|
268
|
+
reject(path, "Bento drops an empty themeRefs map");
|
|
269
|
+
}
|
|
270
|
+
for (const [key, token] of Object.entries(value)) {
|
|
271
|
+
const segments = key.split(".");
|
|
272
|
+
const match = typeof token === "string"
|
|
273
|
+
? /^\s*([A-Za-z][A-Za-z0-9]*)\s*(?:([+-])\s*(\d{1,3})\s*%)?\s*$/.exec(
|
|
274
|
+
token,
|
|
275
|
+
)
|
|
276
|
+
: null;
|
|
277
|
+
if (
|
|
278
|
+
key.length > 200 || !segments.every((s) =>
|
|
279
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$|^\d+$/.test(s)
|
|
280
|
+
) || segments.some((s) =>
|
|
281
|
+
["__proto__", "constructor", "prototype"].includes(s)
|
|
282
|
+
) || typeof token !== "string" || token.length > 200 || !match ||
|
|
283
|
+
!slots.has(match[1])
|
|
284
|
+
) reject(pointer(path, key), "Bento would drop this palette reference");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
supplementary(value, reference, path);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (rule.oneOf || rule.anyOf) {
|
|
291
|
+
for (const candidate of rule.oneOf ?? rule.anyOf!) {
|
|
292
|
+
try {
|
|
293
|
+
check(value, candidate, path);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error instanceof BentoContractError) continue;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
supplementary(value, candidate, path);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// These descriptions are pinned schema metadata, not caller-supplied hints.
|
|
303
|
+
const description = (rule as Schema & { description?: string }).description ??
|
|
304
|
+
"";
|
|
305
|
+
if (description === "CSS colour or url(#local-id)") color(value, path, true);
|
|
306
|
+
if (description === "CSS colour; no url()") color(value, path);
|
|
307
|
+
if (
|
|
308
|
+
description === "SVG path data" &&
|
|
309
|
+
(typeof value !== "string" || !pathData.test(value))
|
|
310
|
+
) reject(path, "Bento would drop invalid SVG path data");
|
|
311
|
+
if (
|
|
312
|
+
description === "data: URI, asset:<key> or URL" && typeof value === "string"
|
|
313
|
+
) {
|
|
314
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
315
|
+
if (!/^(?:data|asset|blob|https?):/i.test(value)) {
|
|
316
|
+
reject(path, "Bento would drop this resource scheme");
|
|
317
|
+
}
|
|
318
|
+
} else css(value, path);
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
description === "slide id or http(s) URL" && typeof value === "string" &&
|
|
322
|
+
!(value.length <= 2048 && webUrl.test(value))
|
|
323
|
+
) css(value, path);
|
|
324
|
+
if (Array.isArray(value) && rule.items) {
|
|
325
|
+
value.forEach((child, i) =>
|
|
326
|
+
supplementary(child, rule.items!, pointer(path, i))
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (isObject(value)) {
|
|
330
|
+
for (const [key, child] of Object.entries(value)) {
|
|
331
|
+
const property = rule.properties?.[key];
|
|
332
|
+
if (property) supplementary(child, property, pointer(path, key));
|
|
333
|
+
else if (isObject(rule.additionalProperties)) {
|
|
334
|
+
supplementary(
|
|
335
|
+
child,
|
|
336
|
+
rule.additionalProperties as Schema,
|
|
337
|
+
pointer(path, key),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Validate before saving/capturing/importing. Never repairs or discards a field. */
|
|
345
|
+
export function assertBentoContract(doc: unknown): void {
|
|
346
|
+
try {
|
|
347
|
+
assertJson(doc);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
reject("", `Expected safe finite plain JSON: ${(error as Error).message}`);
|
|
350
|
+
}
|
|
351
|
+
check(doc, schema, "");
|
|
352
|
+
const object = doc as RecordValue;
|
|
353
|
+
if (object.version !== 1) {
|
|
354
|
+
reject(
|
|
355
|
+
"/version",
|
|
356
|
+
"Only Bento format version 1 is supported by this pinned shell",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (typeof object.docId !== "string" || !object.docId) {
|
|
360
|
+
reject("/docId", "A project requires a stable nonempty document identity");
|
|
361
|
+
}
|
|
362
|
+
supplementary(doc, schema, "");
|
|
363
|
+
for (const collection of ["slides", "layouts"]) {
|
|
364
|
+
if (!Array.isArray(object[collection])) continue;
|
|
365
|
+
const seenSlides = new Set();
|
|
366
|
+
(object[collection] as RecordValue[]).forEach((slide, i) => {
|
|
367
|
+
const at = `/${collection}/${i}`;
|
|
368
|
+
if (!slide.id || seenSlides.has(slide.id)) {
|
|
369
|
+
reject(at + "/id", "Empty or duplicate slide identity");
|
|
370
|
+
}
|
|
371
|
+
seenSlides.add(slide.id);
|
|
372
|
+
if (!Array.isArray(slide.elements)) {
|
|
373
|
+
reject(
|
|
374
|
+
at + "/elements",
|
|
375
|
+
"Project slides require an explicit elements array",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (slide.background !== undefined) {
|
|
379
|
+
color(slide.background, at + "/background", false, true);
|
|
380
|
+
}
|
|
381
|
+
const seenElements = new Set();
|
|
382
|
+
slide.elements.forEach((element, j) => {
|
|
383
|
+
const e = element as RecordValue, p = at + `/elements/${j}`;
|
|
384
|
+
if (!e.id || seenElements.has(e.id)) {
|
|
385
|
+
reject(
|
|
386
|
+
p + "/id",
|
|
387
|
+
"Empty or duplicate element identity within a slide",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
seenElements.add(e.id);
|
|
391
|
+
if (
|
|
392
|
+
e.fontFamily !== undefined && /[<>{};]/.test(e.fontFamily as string)
|
|
393
|
+
) reject(p + "/fontFamily", "Bento would drop this font stack");
|
|
394
|
+
if (
|
|
395
|
+
e.type === "embed" && e.url !== undefined &&
|
|
396
|
+
(!(e.url as string).match(webUrl) || (e.url as string).length > 2048)
|
|
397
|
+
) reject(p + "/url", "Bento accepts only bounded http(s) web URLs");
|
|
398
|
+
if (
|
|
399
|
+
e.pathBox !== undefined &&
|
|
400
|
+
(e.pathBox as number[]).some((n) => n < -1e6 || n > 1e6)
|
|
401
|
+
) reject(p + "/pathBox", "Bento would drop pathBox outside ±1,000,000");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (isObject(object.theme) && object.theme.background !== undefined) {
|
|
406
|
+
color(object.theme.background, "/theme/background", false, true);
|
|
407
|
+
}
|
|
408
|
+
if (isObject(object.assets)) {
|
|
409
|
+
for (const key of Object.keys(object.assets)) {
|
|
410
|
+
if (!key || key.length > 200) {
|
|
411
|
+
reject(pointer("/assets", key), "Bento would drop this asset identity");
|
|
412
|
+
}
|
|
413
|
+
css(key, pointer("/assets", key));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (Array.isArray(object.fonts)) {
|
|
417
|
+
object.fonts.forEach((font: RecordValue, i: number) => {
|
|
418
|
+
if (!font.asset) {
|
|
419
|
+
reject(
|
|
420
|
+
`/fonts/${i}/asset`,
|
|
421
|
+
"A font requires a nonempty asset reference",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
for (const field of ["asset", "weight", "style"]) {
|
|
425
|
+
if (font[field] !== undefined) {
|
|
426
|
+
css(font[field], `/fonts/${i}/${field}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (/[<>{};]/.test(font.family as string)) {
|
|
430
|
+
reject(`/fonts/${i}/family`, "Bento would drop this font family");
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (isObject(object.blobs)) {
|
|
435
|
+
for (const [key, entry] of Object.entries(object.blobs)) {
|
|
436
|
+
const at = pointer("/blobs", key);
|
|
437
|
+
if (
|
|
438
|
+
key.length > 200 || !isObject(entry) || typeof entry.key !== "string" ||
|
|
439
|
+
entry.key.length > 200 || typeof entry.mime !== "string" ||
|
|
440
|
+
entry.mime.length > 100 || typeof entry.size !== "number" ||
|
|
441
|
+
!Number.isFinite(entry.size) || Object.keys(entry).some((k) =>
|
|
442
|
+
!["key", "mime", "size"].includes(k)
|
|
443
|
+
)
|
|
444
|
+
) {
|
|
445
|
+
reject(at, "Bento would drop or trim this blob reference");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A saved project must retain its identity when the upstream shell opens it. */
|
|
452
|
+
export function assertProjectDocument(doc: unknown): void {
|
|
453
|
+
assertBentoContract(doc);
|
|
454
|
+
const value = doc as RecordValue;
|
|
455
|
+
// A valid upstream reference can still depend on a browser session or the
|
|
456
|
+
// network. Project saves must not report success for resources that disappear
|
|
457
|
+
// on reopen. Outgoing hyperlinks are deliberately not resource references.
|
|
458
|
+
const assets = isObject(value.assets) ? value.assets : {};
|
|
459
|
+
const asset = (key: string, path: string): string => {
|
|
460
|
+
if (!Object.hasOwn(assets, key) || typeof assets[key] !== "string") {
|
|
461
|
+
reject(
|
|
462
|
+
path,
|
|
463
|
+
`Missing embedded asset ${
|
|
464
|
+
JSON.stringify(key)
|
|
465
|
+
}; embed the resource before saving this offline project`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
return assets[key] as string;
|
|
469
|
+
};
|
|
470
|
+
const inspected = (fn: () => void, path: string) => {
|
|
471
|
+
try {
|
|
472
|
+
fn();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof ResourceDependencyError) reject(path, error.message);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const embedded = (ref: string, path: string) => {
|
|
479
|
+
if (!ref) return; // An explicit empty native placeholder has no resource.
|
|
480
|
+
const resolved = ref.startsWith("asset:") ? asset(ref.slice(6), path) : ref;
|
|
481
|
+
inspected(() => assertOfflineSvgData(resolved), path);
|
|
482
|
+
if (!/^data:/i.test(resolved)) {
|
|
483
|
+
reject(
|
|
484
|
+
path,
|
|
485
|
+
"Project image, media and font resources must be embedded data, not remote URLs, relative paths or temporary blob URLs; import the local file before saving",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
for (const collection of ["slides", "layouts"]) {
|
|
490
|
+
if (!Array.isArray(value[collection])) continue;
|
|
491
|
+
(value[collection] as RecordValue[]).forEach((slide, i) => {
|
|
492
|
+
(slide.elements as RecordValue[]).forEach((element, j) => {
|
|
493
|
+
const at = `/${collection}/${i}/elements/${j}`;
|
|
494
|
+
if (element.type === "image" || element.type === "media") {
|
|
495
|
+
if (typeof element.src === "string") {
|
|
496
|
+
embedded(element.src, at + "/src");
|
|
497
|
+
}
|
|
498
|
+
if (typeof element.poster === "string") {
|
|
499
|
+
embedded(element.poster, at + "/poster");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (element.type === "svg") {
|
|
503
|
+
const key = element.asset ? "asset" : "markup";
|
|
504
|
+
const markup = element.asset
|
|
505
|
+
? asset(element.asset as string, at + "/asset")
|
|
506
|
+
: element.markup;
|
|
507
|
+
if (typeof markup === "string") {
|
|
508
|
+
inspected(() => assertOfflineSvg(markup), at + "/" + key);
|
|
509
|
+
}
|
|
510
|
+
if (typeof element.css === "string") {
|
|
511
|
+
inspected(
|
|
512
|
+
() => assertOfflineSvgCss(element.css as string),
|
|
513
|
+
at + "/css",
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (element.type === "code") {
|
|
518
|
+
for (const key of ["grammarAssetId", "themeAssetId"]) {
|
|
519
|
+
if (element[key]) asset(element[key] as string, at + "/" + key);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (element.type === "embed") {
|
|
523
|
+
if (typeof element.view === "string") {
|
|
524
|
+
const view = element.view.startsWith("asset:")
|
|
525
|
+
? asset(element.view.slice(6), at + "/view")
|
|
526
|
+
: element.view;
|
|
527
|
+
inspected(() => assertOfflineSvg(view), at + "/view");
|
|
528
|
+
}
|
|
529
|
+
for (const key of ["view", "doc"]) {
|
|
530
|
+
const ref = element[key];
|
|
531
|
+
if (typeof ref === "string" && ref.startsWith("asset:")) {
|
|
532
|
+
asset(ref.slice(6), at + "/" + key);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (Array.isArray(value.fonts)) {
|
|
540
|
+
(value.fonts as RecordValue[]).forEach((font, i) => {
|
|
541
|
+
const at = `/fonts/${i}/asset`;
|
|
542
|
+
// Exact keys from fonts.ts at BENTO_CONTRACT_REVISION. The shell ships
|
|
543
|
+
// these bytes, and native serialization adopts the built-in references.
|
|
544
|
+
// An explicit asset table entry takes precedence in the upstream resolver.
|
|
545
|
+
if (
|
|
546
|
+
!Object.hasOwn(assets, font.asset as string) &&
|
|
547
|
+
["builtin:fraunces-900", "builtin:instrument-sans"].includes(
|
|
548
|
+
font.asset as string,
|
|
549
|
+
)
|
|
550
|
+
) return;
|
|
551
|
+
embedded("asset:" + font.asset, at);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
if (value.template === true) {
|
|
555
|
+
reject(
|
|
556
|
+
"/template",
|
|
557
|
+
"Instantiate a template as an independent project before saving; native template loading creates a new document identity",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (value.collab !== undefined) {
|
|
561
|
+
const c = value.collab;
|
|
562
|
+
if (
|
|
563
|
+
!isObject(c) || c.room !== "" || c.key !== "" || c.on !== false ||
|
|
564
|
+
Object.keys(c).sort().join(",") !== "key,on,room"
|
|
565
|
+
) {
|
|
566
|
+
reject(
|
|
567
|
+
"/collab",
|
|
568
|
+
"Project editing requires the explicit credential-free disabled collaboration sentinel",
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Fresh executed tables only: no persisted bundle or self-authenticated asset loading. */
|
|
2
|
+
import { hashSource, type SourceDocument } from "../source/mod.ts";
|
|
3
|
+
import { canonical, sha256 } from "../state/mod.ts";
|
|
4
|
+
import type { Ast, Element, Json, Origin } from "../render/types.ts";
|
|
5
|
+
import {
|
|
6
|
+
collectRichTableCandidates,
|
|
7
|
+
type RichTableCandidate,
|
|
8
|
+
richTableHtml,
|
|
9
|
+
type RichTableSnapshot,
|
|
10
|
+
snapshotRichTable,
|
|
11
|
+
} from "./table.ts";
|
|
12
|
+
function fail(message: string): never {
|
|
13
|
+
throw new Error(`RICH_TABLE_PROJECTION_MISMATCH: ${message}`);
|
|
14
|
+
}
|
|
15
|
+
export async function prepareRichTables(ast: Ast, source: SourceDocument) {
|
|
16
|
+
const candidates = collectRichTableCandidates(ast, source);
|
|
17
|
+
if (candidates.length > 8) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"UNSUPPORTED_RICH_TABLE: this bounded capture supports up to eight rich tables per deck",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const snapshots = new Map<string, RichTableSnapshot>();
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
snapshots.set(
|
|
25
|
+
candidate.source.sourceId,
|
|
26
|
+
await snapshotRichTable(candidate),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return snapshots;
|
|
30
|
+
}
|
|
31
|
+
/** Rebind in-memory capture to the same original source and current executed AST. */
|
|
32
|
+
export async function verifyRichTableSnapshot(
|
|
33
|
+
expected: RichTableCandidate,
|
|
34
|
+
snapshot: RichTableSnapshot,
|
|
35
|
+
) {
|
|
36
|
+
const receipt = snapshot.receipt;
|
|
37
|
+
if (
|
|
38
|
+
canonical(snapshot.candidate) !== canonical(expected) ||
|
|
39
|
+
receipt.version !== 1 || receipt.kind !== "gt-static-png" ||
|
|
40
|
+
canonical(receipt.source) !== canonical(expected.source) ||
|
|
41
|
+
receipt.htmlHash !== hashSource(await richTableHtml(expected)) ||
|
|
42
|
+
receipt.pngHash !== await sha256(snapshot.png) ||
|
|
43
|
+
receipt.width !== 1120 || !Number.isSafeInteger(receipt.height) ||
|
|
44
|
+
receipt.height < 1 || receipt.height > 534 || receipt.pixelRatio !== 2 ||
|
|
45
|
+
receipt.minFontSize < 8 || receipt.minBodyFontSize < 10
|
|
46
|
+
) {
|
|
47
|
+
fail(
|
|
48
|
+
"capture does not match its checked executed fragment and PNG receipt",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const png = snapshot.png;
|
|
52
|
+
if (
|
|
53
|
+
png.length < 24 ||
|
|
54
|
+
canonical(Array.from(png.slice(0, 8))) !==
|
|
55
|
+
canonical([137, 80, 78, 71, 13, 10, 26, 10])
|
|
56
|
+
) {
|
|
57
|
+
fail("capture is not a PNG");
|
|
58
|
+
}
|
|
59
|
+
const header = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
60
|
+
if (
|
|
61
|
+
header.getUint32(16) !== receipt.width * 2 ||
|
|
62
|
+
header.getUint32(20) !== receipt.height * 2
|
|
63
|
+
) {
|
|
64
|
+
fail("PNG dimensions differ from the measured complete table");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function richTableElement(
|
|
68
|
+
snapshot: RichTableSnapshot,
|
|
69
|
+
): { element: Element; origin: Origin } {
|
|
70
|
+
let binary = "";
|
|
71
|
+
for (let i = 0; i < snapshot.png.length; i += 8192) {
|
|
72
|
+
binary += String.fromCharCode(...snapshot.png.subarray(i, i + 8192));
|
|
73
|
+
}
|
|
74
|
+
const source = snapshot.candidate.source;
|
|
75
|
+
return {
|
|
76
|
+
element: {
|
|
77
|
+
id: `${source.sourceId}/table-fallback`,
|
|
78
|
+
type: "image",
|
|
79
|
+
x: 80,
|
|
80
|
+
y: 166,
|
|
81
|
+
w: snapshot.receipt.width,
|
|
82
|
+
h: snapshot.receipt.height,
|
|
83
|
+
rotation: 0,
|
|
84
|
+
opacity: 1,
|
|
85
|
+
src: `data:image/png;base64,${btoa(binary)}`,
|
|
86
|
+
fit: "contain",
|
|
87
|
+
},
|
|
88
|
+
origin: {
|
|
89
|
+
origin: "computed",
|
|
90
|
+
sourceId: source.producerId,
|
|
91
|
+
producer: source.producerId,
|
|
92
|
+
// The raw HTML/AST hashes include random gt CSS IDs. Verify them freshly,
|
|
93
|
+
// but publish only stable content evidence so reconcile can re-execute.
|
|
94
|
+
richTable: {
|
|
95
|
+
version: snapshot.receipt.version,
|
|
96
|
+
kind: snapshot.receipt.kind,
|
|
97
|
+
source: {
|
|
98
|
+
sourceHash: source.sourceHash,
|
|
99
|
+
sourceId: source.sourceId,
|
|
100
|
+
producerId: source.producerId,
|
|
101
|
+
slideId: source.slideId,
|
|
102
|
+
chunkHash: source.chunkHash,
|
|
103
|
+
},
|
|
104
|
+
pngHash: snapshot.receipt.pngHash,
|
|
105
|
+
textHash: snapshot.receipt.textHash,
|
|
106
|
+
browser: snapshot.receipt.browser,
|
|
107
|
+
width: snapshot.receipt.width,
|
|
108
|
+
height: snapshot.receipt.height,
|
|
109
|
+
pixelRatio: snapshot.receipt.pixelRatio,
|
|
110
|
+
rows: snapshot.receipt.rows,
|
|
111
|
+
cells: snapshot.receipt.cells,
|
|
112
|
+
minFontSize: snapshot.receipt.minFontSize,
|
|
113
|
+
minBodyFontSize: snapshot.receipt.minBodyFontSize,
|
|
114
|
+
} as Json,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|