partforge 0.97.0 → 0.98.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/bin/cli.js +115 -9
- package/docs/AUTHORING-PARTS.md +181 -13
- package/docs/ERROR-PATTERNS.md +3 -3
- package/docs/VECTOR-FORMAT.md +23 -17
- package/package.json +9 -1
- package/src/framework/font-source.js +18 -1
- package/src/framework/image-source.js +12 -8
- package/src/framework/images.js +1 -1
- package/src/framework/{image-ingest.js → ingest/image-ingest.js} +2 -2
- package/src/framework/ingest/node-dom.js +69 -0
- package/src/framework/ingest/registry.js +57 -0
- package/src/framework/ingest/sniff.js +96 -0
- package/src/framework/jobs.js +52 -6
- package/src/framework/lint/rules-vector.js +68 -3
- package/src/framework/mount.js +13 -1
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/file-drop.js +321 -0
- package/src/framework/panel/widgets/font.js +51 -12
- package/src/framework/panel/widgets/image.js +17 -3
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/framework/panel/widgets/vector.js +77 -0
- package/src/framework/vector-source.js +127 -0
- package/src/framework/vectors.js +9 -0
- package/src/index.js +0 -1
- package/src/ingest.js +1 -0
- package/src/parts/emblem.js +2 -1
- package/types/index.d.ts +66 -12
- package/types/ingest.d.ts +13 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What a PARAM-supplied vector source may be. Author-declared `vectors`
|
|
2
|
+
// sources are code and get no restriction; this file exists only for the
|
|
3
|
+
// other case — a value that arrived in `params`, which on a shared link is
|
|
4
|
+
// attacker-controlled input that `vectors: (p) => …` would turn into a fetch
|
|
5
|
+
// URL.
|
|
6
|
+
//
|
|
7
|
+
// This file's allow rule differs from its two siblings
|
|
8
|
+
// (font-source.js/image-source.js) in ONE place, and getting that difference
|
|
9
|
+
// right matters: those two exempt every non-string (bytes) source from the
|
|
10
|
+
// allow check on the reasoning that an ArrayBuffer cannot survive a share
|
|
11
|
+
// link — a URL can't carry megabytes, so bytes in params can only have been
|
|
12
|
+
// placed there by the host's own trusted panel. That reasoning does NOT
|
|
13
|
+
// transfer to vector artwork: `type: "vector"`'s drop target writes a PARSED
|
|
14
|
+
// partforge-vector document — plain JSON — into params when there is no upload
|
|
15
|
+
// hook, and unlike raw bytes, plain JSON round-trips a share link perfectly
|
|
16
|
+
// (it's just more of the same params payload). Do not copy the
|
|
17
|
+
// bytes-can't-survive-a-link justification here; it would be wrong.
|
|
18
|
+
//
|
|
19
|
+
// So the exemption this file grants is NOT "any object". The gate has to be
|
|
20
|
+
// read against what asset-resolve.js actually does, in its order:
|
|
21
|
+
// `unwrapModule(v)` runs FIRST, and only THEN does the resolver dispatch on
|
|
22
|
+
// shape. A `{ default: "http://169.254.169.254/…" }` wrapper is therefore
|
|
23
|
+
// unwrapped to a plain string and handed to `fetch` — arbitrary scheme,
|
|
24
|
+
// arbitrary host, in ~40 bytes of JSON a share link carries effortlessly. An
|
|
25
|
+
// earlier version of this file exempted every object on the claim that "an
|
|
26
|
+
// object never reaches the fetch branch"; that claim was FALSE for exactly
|
|
27
|
+
// this shape, and it is deleted rather than qualified.
|
|
28
|
+
//
|
|
29
|
+
// The rule that does hold, and what this file gates on, is: unwrap first, then
|
|
30
|
+
// judge the value the RESOLVER will see.
|
|
31
|
+
//
|
|
32
|
+
// - a string or `URL` — fetchable, so it gets the full `allow` treatment;
|
|
33
|
+
// - bytes (ArrayBuffer/view) — never handed to `fetch`, and (unlike JSON)
|
|
34
|
+
// genuinely cannot ride a link, so exempt;
|
|
35
|
+
// - a function/thunk — refused outright. Its return value is a fetch source
|
|
36
|
+
// that cannot be known at check time, so there is nothing to gate on;
|
|
37
|
+
// - a plain object that does NOT unwrap to any of the above — the genuinely
|
|
38
|
+
// inert case, the already-parsed partforge-vector document vectors.js's
|
|
39
|
+
// `asParsedFile` claims. It is validated downstream by `toInternalDocument`,
|
|
40
|
+
// a pure shape check that never touches the network, so it is exempt;
|
|
41
|
+
// - anything else (arrays, numbers, booleans) — refused. None of them is a
|
|
42
|
+
// valid source, and a refusal simply restores the part's default.
|
|
43
|
+
//
|
|
44
|
+
// `unwrapModule` is imported, never re-implemented: this gate is only sound
|
|
45
|
+
// while it unwraps by exactly the same rule the resolver does.
|
|
46
|
+
//
|
|
47
|
+
// DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
|
|
48
|
+
// asset-resolve.js is itself dependency-free, so importing it keeps
|
|
49
|
+
// partforge/lint's zero-dependency closure intact.
|
|
50
|
+
import { unwrapModule } from "./asset-resolve.js";
|
|
51
|
+
|
|
52
|
+
export const VECTOR_ALLOW_DEFAULT = ["https"];
|
|
53
|
+
|
|
54
|
+
const ASSET_SCHEME = "pfc-asset:";
|
|
55
|
+
|
|
56
|
+
// The "unset" vector source. An empty value declares NO artwork for that
|
|
57
|
+
// name — mirrors isNoFontSource/isNoImageSource exactly. Never a source to
|
|
58
|
+
// fetch, and never a source to refuse.
|
|
59
|
+
export const isNoVectorSource = (v) => v === undefined || v === null || v === "";
|
|
60
|
+
|
|
61
|
+
const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
62
|
+
|
|
63
|
+
// An already-parsed partforge-vector document: object-shaped, and not any of
|
|
64
|
+
// the shapes the resolver reads as a way to REACH bytes. Deliberately the same
|
|
65
|
+
// structural test vectors.js's `asParsedFile` applies (arrays excluded — an
|
|
66
|
+
// array is never a file), so "what this gate exempts" and "what the resolver
|
|
67
|
+
// adopts without fetching" stay the same set.
|
|
68
|
+
const isParsedDocument = (v) =>
|
|
69
|
+
v != null && typeof v === "object" && !Array.isArray(v)
|
|
70
|
+
&& !isBytes(v) && !(v instanceof URL);
|
|
71
|
+
|
|
72
|
+
// Parse once; an unparseable string is refused rather than guessed at.
|
|
73
|
+
function parse(source) {
|
|
74
|
+
try { return new URL(source); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function vectorSourceAllowed(source, allow = VECTOR_ALLOW_DEFAULT) {
|
|
78
|
+
// Unwrap FIRST — asset-resolve.js does, before it dispatches on shape, so a
|
|
79
|
+
// `{ default: … }` wrapper must be judged by what it unwraps to. See header.
|
|
80
|
+
const v = unwrapModule(source);
|
|
81
|
+
// A thunk (before or after unwrapping) resolves to a source this check cannot
|
|
82
|
+
// see, so there is nothing to gate; refuse rather than trust it.
|
|
83
|
+
if (typeof source === "function" || typeof v === "function") return false;
|
|
84
|
+
if (isBytes(v)) return true; // never handed to `fetch`
|
|
85
|
+
if (isParsedDocument(v)) return true; // inert; validated, never fetched
|
|
86
|
+
// Everything the resolver would fetch — a string or a `URL` — gets the full
|
|
87
|
+
// allow treatment. Everything else (arrays, numbers, booleans) is refused.
|
|
88
|
+
if (!(typeof v === "string" || v instanceof URL)) return false;
|
|
89
|
+
const u = v instanceof URL ? v : parse(v);
|
|
90
|
+
if (!u) return false;
|
|
91
|
+
for (const kind of allow) {
|
|
92
|
+
// hostname/protocol, never a substring of the raw string — same rule
|
|
93
|
+
// font-source.js's header explains: a URL merely CONTAINING
|
|
94
|
+
// "pfc-asset://" must not pass, and neither must a lookalike host.
|
|
95
|
+
if (kind === "https" && u.protocol === "https:") return true;
|
|
96
|
+
if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// paramKey → allow list, for every `type: "vector"` control in the authored
|
|
102
|
+
// tree — new-shape (`controls`, including nested groups) AND legacy-shape
|
|
103
|
+
// (`advanced`/`toggles`/`features`, where panel/legacy.js desugars a
|
|
104
|
+
// descriptor's `control:` field to `type:`), mirroring
|
|
105
|
+
// imageControlAllows/fontControlAllows exactly. Missing the legacy arrays
|
|
106
|
+
// here would leave a `{key, control:"vector"}` descriptor with no entry in
|
|
107
|
+
// the returned map, and jobs.js's check only looks at keys present in the
|
|
108
|
+
// map — so a legacy-declared vector control would get silently
|
|
109
|
+
// unrestricted. Tolerant of any array being absent or malformed; it must
|
|
110
|
+
// never throw on an existing part.
|
|
111
|
+
export function vectorControlAllows(part) {
|
|
112
|
+
const out = new Map();
|
|
113
|
+
const visit = (nodes) => {
|
|
114
|
+
for (const n of nodes ?? []) {
|
|
115
|
+
if (!n || typeof n !== "object") continue;
|
|
116
|
+
if (Array.isArray(n.controls)) visit(n.controls);
|
|
117
|
+
if (Array.isArray(n.advanced)) visit(n.advanced);
|
|
118
|
+
if (Array.isArray(n.toggles)) visit(n.toggles);
|
|
119
|
+
if (Array.isArray(n.features)) visit(n.features);
|
|
120
|
+
if ((n.type === "vector" || n.control === "vector") && typeof n.key === "string") {
|
|
121
|
+
out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : VECTOR_ALLOW_DEFAULT);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
visit(part?.parameters);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
package/src/framework/vectors.js
CHANGED
|
@@ -83,6 +83,15 @@ const resolveOne = makeAssetResolver(
|
|
|
83
83
|
(value) => asParsedFile(value) ?? undefined,
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
// `vectors` may be a plain { name: source } map, or a function of the resolved
|
|
87
|
+
// params — the second form is what lets a `type: "vector"` control drive the
|
|
88
|
+
// artwork. Resolving it needs `p`, which is why this is a separate step from
|
|
89
|
+
// resolveVectors rather than folded into it. Mirrors fontsFor/imagesFor.
|
|
90
|
+
export function vectorsFor(part, p) {
|
|
91
|
+
const decl = part?.vectors;
|
|
92
|
+
return typeof decl === "function" ? decl(p) : decl;
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
export async function resolveVectors(vectorsDecl) {
|
|
87
96
|
// A function reaching here means a caller passed `part.vectors` raw, the way
|
|
88
97
|
// fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
|
package/src/index.js
CHANGED
package/src/ingest.js
CHANGED
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
// Deliberately NOT re-exported from `partforge` (the main entry) or from
|
|
7
7
|
// `partforge/geometry`: this must stay unreachable from the geometry worker.
|
|
8
8
|
export { ingestSvg } from "./framework/ingest/svg-ingest.js";
|
|
9
|
+
export { imageToPng } from "./framework/ingest/image-ingest.js";
|
package/src/parts/emblem.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// work under Vite and fail in the CLI.
|
|
20
20
|
//
|
|
21
21
|
// The source artwork lives beside it as emblem.svg, and the .json is regenerated
|
|
22
|
-
// with `
|
|
22
|
+
// with `npx partforge ingest src/parts/assets/emblem.svg --out
|
|
23
|
+
// src/parts/assets/emblem.vector.json`. plate.vector.json
|
|
23
24
|
// is hand-authored — no ingest step, no source SVG — and is kept legible enough
|
|
24
25
|
// to serve as documentation's worked example of a multi-shape, role-composed file.
|
|
25
26
|
import plate from "./assets/plate.vector.json" with { type: "json" };
|
package/types/index.d.ts
CHANGED
|
@@ -101,6 +101,50 @@ export interface MountElements {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** Which control family a dropped file belongs to. */
|
|
105
|
+
export type AssetKind = "image" | "vector" | "font";
|
|
106
|
+
|
|
107
|
+
/** One selectable face in a `FontCatalog` family. */
|
|
108
|
+
export interface FontVariant {
|
|
109
|
+
variant: string;
|
|
110
|
+
label: string;
|
|
111
|
+
/** What the picker writes into `params`. */
|
|
112
|
+
url: string;
|
|
113
|
+
bytes?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface FontFamily {
|
|
117
|
+
id: string;
|
|
118
|
+
family: string;
|
|
119
|
+
category?: string;
|
|
120
|
+
variants: FontVariant[];
|
|
121
|
+
/** A name-only subset used to draw the list row. */
|
|
122
|
+
menuUrl?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Backs `type: "font"` controls. Supplied by the host; partforge ships none. */
|
|
126
|
+
export interface FontCatalog {
|
|
127
|
+
search: (query: string, opts: { limit?: number }) => Promise<FontFamily[]>;
|
|
128
|
+
/** Reverse lookup, so a hashed-filename URL can still be named in the UI. */
|
|
129
|
+
describe?: (source: string) => { family: string; variant: string } | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ImageAsset {
|
|
133
|
+
id: string;
|
|
134
|
+
label: string;
|
|
135
|
+
/** What the picker writes into `params`. */
|
|
136
|
+
url: string;
|
|
137
|
+
width?: number;
|
|
138
|
+
height?: number;
|
|
139
|
+
thumbUrl?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Backs `type: "image"` controls. Supplied by the host; partforge ships none. */
|
|
143
|
+
export interface ImageCatalog {
|
|
144
|
+
search: (query: string, opts: { limit?: number }) => Promise<ImageAsset[]>;
|
|
145
|
+
describe?: (source: string) => { label: string; width: number; height: number } | null;
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
export interface MountOptions {
|
|
105
149
|
/**
|
|
106
150
|
* Spawns a geometry worker. Called once per backend with `name` as the
|
|
@@ -143,6 +187,28 @@ export interface MountOptions {
|
|
|
143
187
|
* part cannot support is dropped, never fatal.
|
|
144
188
|
*/
|
|
145
189
|
viewerState?: ViewerState | null;
|
|
190
|
+
/**
|
|
191
|
+
* A provider backing every `type: "font"` control in the part. partforge
|
|
192
|
+
* ships none — without one, a font control renders as a plain URL field.
|
|
193
|
+
*/
|
|
194
|
+
fontCatalog?: FontCatalog;
|
|
195
|
+
/**
|
|
196
|
+
* A provider backing every `type: "image"` control in the part. Without one,
|
|
197
|
+
* an image control degrades to a plain URL field.
|
|
198
|
+
*/
|
|
199
|
+
imageCatalog?: ImageCatalog;
|
|
200
|
+
/**
|
|
201
|
+
* The upload hook for the drop target shared by the `"image"`, `"vector"`
|
|
202
|
+
* and `"font"` controls. Called with the CONVERTED artifact — a PNG, a
|
|
203
|
+
* partforge-vector document serialized as JSON, or the original file for a
|
|
204
|
+
* font — never the user's raw drop, and must resolve to a non-empty source
|
|
205
|
+
* string (an `https:` URL, or a host-defined `pfc-asset:` token) which is
|
|
206
|
+
* written into the param. Anything else, or a rejection, is reported through
|
|
207
|
+
* the control's own error line and never written. Omit it and the converted
|
|
208
|
+
* artifact lands in the param directly — the path a host that cannot fetch
|
|
209
|
+
* URLs needs, not a degraded fallback.
|
|
210
|
+
*/
|
|
211
|
+
onAssetUpload?: (blob: Blob, info: { kind: AssetKind; filename: string }) => Promise<string>;
|
|
146
212
|
/** @deprecated alias for `elements.viewer`. */
|
|
147
213
|
container?: HTMLElement | null;
|
|
148
214
|
/** @deprecated alias for `elements.controls`. */
|
|
@@ -455,15 +521,3 @@ export function viewSubParts(
|
|
|
455
521
|
params: Record<string, ParamValue>,
|
|
456
522
|
): string[];
|
|
457
523
|
|
|
458
|
-
export interface ImageToPngOptions {
|
|
459
|
-
/** Long-edge cap in px; an image already under this is not upscaled. Default 1024. */
|
|
460
|
-
maxSize?: number;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Convert any image the browser can decode into a PNG `Blob`, downsampling the
|
|
465
|
-
* long edge to `maxSize` on the way. Main-thread only (uses `createImageBitmap`
|
|
466
|
-
* and a canvas) — for a host normalising uploads before storing them, since a
|
|
467
|
-
* part's `images` field decodes PNG only. Never call from a part's `build`.
|
|
468
|
-
*/
|
|
469
|
-
export function imageToPng(fileOrBlob: Blob | File, options?: ImageToPngOptions): Promise<Blob>;
|
package/types/ingest.d.ts
CHANGED
|
@@ -116,3 +116,16 @@ export interface IngestSvgOptions {
|
|
|
116
116
|
* element is `fill="none"` with no stroke, hidden, or empty).
|
|
117
117
|
*/
|
|
118
118
|
export function ingestSvg(svgText: string, opts?: IngestSvgOptions): VectorDocument;
|
|
119
|
+
|
|
120
|
+
export interface ImageToPngOptions {
|
|
121
|
+
/** Long-edge cap in px; an image already under this is not upscaled. Default 1024. */
|
|
122
|
+
maxSize?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convert any image the browser can decode into a PNG `Blob`, downsampling the
|
|
127
|
+
* long edge to `maxSize` on the way. Main-thread only (uses `createImageBitmap`
|
|
128
|
+
* and a canvas) — for a host normalising uploads before storing them, since a
|
|
129
|
+
* part's `images` field decodes PNG only. Never call from a part's `build`.
|
|
130
|
+
*/
|
|
131
|
+
export function imageToPng(fileOrBlob: Blob | File, options?: ImageToPngOptions): Promise<Blob>;
|