@1agh/maude 0.43.0 → 0.44.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// _import-brand.mjs — DDR-173: extract typed design cues (palette + font
|
|
2
|
+
// classification + a hardened logo asset) from an already-DDR-167-sanitized
|
|
3
|
+
// SVG, for `/design:setup-ds --from-brand` (T12). NEVER re-reads the original
|
|
4
|
+
// brand file or re-runs DDR-167's sanitize/canary pipeline — operates only on
|
|
5
|
+
// the sanitized output `maude design import-asset` already produced (DDR-173
|
|
6
|
+
// Decision 2: no parallel, ungated read path).
|
|
7
|
+
//
|
|
8
|
+
// Every numbered "Decision N" comment below points at the exact DDR-173
|
|
9
|
+
// clause it implements.
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join, resolve, sep } from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import { Window } from 'happy-dom';
|
|
25
|
+
import { withSandboxedRender } from './_import-asset.mjs';
|
|
26
|
+
import { isValidColorValue } from './_import-tokens.mjs';
|
|
27
|
+
|
|
28
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
29
|
+
|
|
30
|
+
export class ImportBrandError extends Error {
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Exit codes: 0 ok · 2 usage · 3 validation/hardening reject · 4 read/parse
|
|
38
|
+
// error · 6 write/containment error · 1 other.
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Decision 1 — font-family classification: exact match against a curated
|
|
42
|
+
// allowlist of real, known font names. The RAW extracted string is NEVER
|
|
43
|
+
// forwarded — only the matched entry from this list, or a fixed generic
|
|
44
|
+
// keyword. A value that doesn't exact-match is discarded, never guessed.
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
export const FONT_ALLOWLIST = [
|
|
48
|
+
// System / web-safe
|
|
49
|
+
'Arial',
|
|
50
|
+
'Helvetica',
|
|
51
|
+
'Helvetica Neue',
|
|
52
|
+
'Times New Roman',
|
|
53
|
+
'Times',
|
|
54
|
+
'Georgia',
|
|
55
|
+
'Verdana',
|
|
56
|
+
'Tahoma',
|
|
57
|
+
'Trebuchet MS',
|
|
58
|
+
'Courier New',
|
|
59
|
+
'Courier',
|
|
60
|
+
'Impact',
|
|
61
|
+
'Comic Sans MS',
|
|
62
|
+
'Lucida Console',
|
|
63
|
+
'Lucida Sans Unicode',
|
|
64
|
+
'Palatino',
|
|
65
|
+
'Garamond',
|
|
66
|
+
'Book Antiqua',
|
|
67
|
+
'Segoe UI',
|
|
68
|
+
'Calibri',
|
|
69
|
+
'Cambria',
|
|
70
|
+
'Candara',
|
|
71
|
+
'Consolas',
|
|
72
|
+
'Constantia',
|
|
73
|
+
'Corbel',
|
|
74
|
+
'Franklin Gothic Medium',
|
|
75
|
+
// Common web fonts (Google Fonts + popular type foundries)
|
|
76
|
+
'Inter',
|
|
77
|
+
'Inter Tight',
|
|
78
|
+
'Roboto',
|
|
79
|
+
'Roboto Mono',
|
|
80
|
+
'Roboto Condensed',
|
|
81
|
+
'Open Sans',
|
|
82
|
+
'Lato',
|
|
83
|
+
'Montserrat',
|
|
84
|
+
'Poppins',
|
|
85
|
+
'Source Sans Pro',
|
|
86
|
+
'Source Serif Pro',
|
|
87
|
+
'Source Code Pro',
|
|
88
|
+
'Nunito',
|
|
89
|
+
'Nunito Sans',
|
|
90
|
+
'Raleway',
|
|
91
|
+
'Ubuntu',
|
|
92
|
+
'Playfair Display',
|
|
93
|
+
'Merriweather',
|
|
94
|
+
'PT Sans',
|
|
95
|
+
'PT Serif',
|
|
96
|
+
'Work Sans',
|
|
97
|
+
'DM Sans',
|
|
98
|
+
'DM Serif Display',
|
|
99
|
+
'Space Grotesk',
|
|
100
|
+
'Space Mono',
|
|
101
|
+
'IBM Plex Sans',
|
|
102
|
+
'IBM Plex Serif',
|
|
103
|
+
'IBM Plex Mono',
|
|
104
|
+
'JetBrains Mono',
|
|
105
|
+
'Fira Sans',
|
|
106
|
+
'Fira Code',
|
|
107
|
+
'Fira Mono',
|
|
108
|
+
'Noto Sans',
|
|
109
|
+
'Noto Serif',
|
|
110
|
+
'Oswald',
|
|
111
|
+
'Bebas Neue',
|
|
112
|
+
'Archivo',
|
|
113
|
+
'Archivo Black',
|
|
114
|
+
'Manrope',
|
|
115
|
+
'Karla',
|
|
116
|
+
'Rubik',
|
|
117
|
+
'Barlow',
|
|
118
|
+
'Cabin',
|
|
119
|
+
'Quicksand',
|
|
120
|
+
'Josefin Sans',
|
|
121
|
+
'Libre Franklin',
|
|
122
|
+
'Libre Baskerville',
|
|
123
|
+
'Crimson Text',
|
|
124
|
+
'EB Garamond',
|
|
125
|
+
'Cormorant',
|
|
126
|
+
'Cormorant Garamond',
|
|
127
|
+
'Lora',
|
|
128
|
+
'Bitter',
|
|
129
|
+
'Zilla Slab',
|
|
130
|
+
'Domine',
|
|
131
|
+
'Spectral',
|
|
132
|
+
'Alegreya',
|
|
133
|
+
'Vollkorn',
|
|
134
|
+
'Geist',
|
|
135
|
+
'Geist Mono',
|
|
136
|
+
'SF Mono',
|
|
137
|
+
'SF Pro',
|
|
138
|
+
'SF Pro Display',
|
|
139
|
+
'SF Pro Text',
|
|
140
|
+
'Menlo',
|
|
141
|
+
'Monaco',
|
|
142
|
+
'Cooper Black',
|
|
143
|
+
'Futura',
|
|
144
|
+
'Gotham',
|
|
145
|
+
'Din',
|
|
146
|
+
'DIN Next',
|
|
147
|
+
'Avenir',
|
|
148
|
+
'Avenir Next',
|
|
149
|
+
'Proxima Nova',
|
|
150
|
+
'Circular',
|
|
151
|
+
'Neue Haas Grotesk',
|
|
152
|
+
].map((n) => Object.freeze(n));
|
|
153
|
+
|
|
154
|
+
const FONT_GENERIC_KEYWORDS = new Set([
|
|
155
|
+
'serif',
|
|
156
|
+
'sans-serif',
|
|
157
|
+
'monospace',
|
|
158
|
+
'system-ui',
|
|
159
|
+
'ui-monospace',
|
|
160
|
+
'ui-sans-serif',
|
|
161
|
+
'ui-serif',
|
|
162
|
+
'cursive',
|
|
163
|
+
'fantasy',
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const FONT_ALLOWLIST_LOWER = new Map(FONT_ALLOWLIST.map((n) => [n.toLowerCase(), n]));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Decision 1 — classify a font-family LIST value (comma-separated, as found
|
|
170
|
+
* in an SVG `font-family` attribute — no CSS quoting assumed). Returns ONLY
|
|
171
|
+
* entries that exact-match (case-insensitive, plain ASCII lowercase — never
|
|
172
|
+
* a locale-sensitive fold) the curated allowlist or a fixed generic keyword;
|
|
173
|
+
* unmatched entries are dropped silently (never fuzzy-matched, never
|
|
174
|
+
* forwarded raw). Grammar gate runs first: any entry containing a
|
|
175
|
+
* non-printable-ASCII character is rejected before the match ever runs.
|
|
176
|
+
*/
|
|
177
|
+
export function classifyFontFamilyList(rawValue) {
|
|
178
|
+
if (typeof rawValue !== 'string') return [];
|
|
179
|
+
const entries = rawValue.split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
|
|
180
|
+
const matched = [];
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (!/^[\x20-\x7E]*$/.test(entry) || entry.length === 0 || entry.length > 64) continue;
|
|
183
|
+
const lower = entry.toLowerCase();
|
|
184
|
+
if (FONT_GENERIC_KEYWORDS.has(lower)) {
|
|
185
|
+
matched.push(lower);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const canonical = FONT_ALLOWLIST_LOWER.get(lower);
|
|
189
|
+
if (canonical) matched.push(canonical);
|
|
190
|
+
}
|
|
191
|
+
return matched;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Decision 6 — exhaustive per-attribute-type grammar for the `--from-brand`
|
|
196
|
+
// logo. Any retained attribute whose value fails its class's grammar is
|
|
197
|
+
// dropped (attribute removed, element kept) — fail-closed default: any
|
|
198
|
+
// attribute with no assigned bucket below is dropped unconditionally.
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
const COLOR_ATTRS = new Set(['fill', 'stroke', 'stop-color']);
|
|
202
|
+
const COLOR_KEYWORDS = new Set(['none', 'currentColor']);
|
|
203
|
+
|
|
204
|
+
const NUMERIC_ATTRS = new Set([
|
|
205
|
+
'x',
|
|
206
|
+
'y',
|
|
207
|
+
'width',
|
|
208
|
+
'height',
|
|
209
|
+
'cx',
|
|
210
|
+
'cy',
|
|
211
|
+
'r',
|
|
212
|
+
'rx',
|
|
213
|
+
'ry',
|
|
214
|
+
'opacity',
|
|
215
|
+
'fill-opacity',
|
|
216
|
+
'stroke-opacity',
|
|
217
|
+
'stroke-width',
|
|
218
|
+
'font-size',
|
|
219
|
+
'offset',
|
|
220
|
+
]);
|
|
221
|
+
const NUMERIC_RE = /^-?[\d.]+(px|%)?$/;
|
|
222
|
+
|
|
223
|
+
const COORD_LIST_ATTRS = new Set(['viewBox']);
|
|
224
|
+
const COORD_LIST_RE = /^-?[\d.]+([ ,]+-?[\d.]+){3}$/;
|
|
225
|
+
|
|
226
|
+
const GEOMETRY_PATH_ATTRS = new Set(['d']);
|
|
227
|
+
const GEOMETRY_POINTS_ATTRS = new Set(['points']);
|
|
228
|
+
const PATH_COMMAND_LETTERS = new Set('MmLlHhVvCcSsQqTtAaZz'.split(''));
|
|
229
|
+
|
|
230
|
+
const TRANSFORM_ATTRS = new Set(['transform', 'gradientTransform', 'patternTransform']);
|
|
231
|
+
const TRANSFORM_FUNCTIONS = new Set([
|
|
232
|
+
'matrix',
|
|
233
|
+
'translate',
|
|
234
|
+
'translateX',
|
|
235
|
+
'translateY',
|
|
236
|
+
'scale',
|
|
237
|
+
'scaleX',
|
|
238
|
+
'scaleY',
|
|
239
|
+
'rotate',
|
|
240
|
+
'skewX',
|
|
241
|
+
'skewY',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const ENUM_ATTRS = {
|
|
245
|
+
'text-anchor': new Set(['start', 'middle', 'end']),
|
|
246
|
+
gradientUnits: new Set(['userSpaceOnUse', 'objectBoundingBox']),
|
|
247
|
+
patternUnits: new Set(['userSpaceOnUse', 'objectBoundingBox']),
|
|
248
|
+
'font-weight': new Set([
|
|
249
|
+
'normal',
|
|
250
|
+
'bold',
|
|
251
|
+
'bolder',
|
|
252
|
+
'lighter',
|
|
253
|
+
'100',
|
|
254
|
+
'200',
|
|
255
|
+
'300',
|
|
256
|
+
'400',
|
|
257
|
+
'500',
|
|
258
|
+
'600',
|
|
259
|
+
'700',
|
|
260
|
+
'800',
|
|
261
|
+
'900',
|
|
262
|
+
]),
|
|
263
|
+
version: new Set(['1.0', '1.1', '2.0']),
|
|
264
|
+
};
|
|
265
|
+
const PRESERVE_ASPECT_RATIO_RE = /^(none|x(?:Min|Mid|Max)Y(?:Min|Mid|Max))(?: (?:meet|slice))?$/;
|
|
266
|
+
|
|
267
|
+
const NAMESPACE_ATTRS = {
|
|
268
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
269
|
+
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const IDENTIFIER_ATTRS = new Set(['id']);
|
|
273
|
+
const IDENTIFIER_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
274
|
+
|
|
275
|
+
const FONT_FAMILY_ATTRS = new Set(['font-family']);
|
|
276
|
+
|
|
277
|
+
// Attributes DDR-167 already value-constrains structurally (FuncIRI
|
|
278
|
+
// #fragment-only / raster data: URIs) — left untouched here, not re-graded.
|
|
279
|
+
const ALREADY_CONSTRAINED_ATTRS = new Set([
|
|
280
|
+
'href',
|
|
281
|
+
'xlink:href',
|
|
282
|
+
'clip-path',
|
|
283
|
+
'mask',
|
|
284
|
+
'filter',
|
|
285
|
+
'marker-start',
|
|
286
|
+
'marker-mid',
|
|
287
|
+
'marker-end',
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
/** Every maximal alphabetic run in `s` must be exactly one char and a member of `allowedSingleChars`. */
|
|
291
|
+
function hasOnlySingleCharAlphaRuns(s, allowedSingleChars) {
|
|
292
|
+
const runs = s.match(/[A-Za-z]+/g) || [];
|
|
293
|
+
return runs.every((run) => run.length === 1 && allowedSingleChars.has(run));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Every maximal alphabetic run in `s` must exact-match a member of `allowedNames` (case-sensitive). */
|
|
297
|
+
function hasOnlyKnownFunctionNames(s, allowedNames) {
|
|
298
|
+
const runs = s.match(/[A-Za-z]+/g) || [];
|
|
299
|
+
return runs.every((run) => allowedNames.has(run));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isValidAttributeValue(name, value) {
|
|
303
|
+
if (typeof value !== 'string' || !/^[\x20-\x7E]*$/.test(value)) return false;
|
|
304
|
+
if (COLOR_ATTRS.has(name)) return COLOR_KEYWORDS.has(value) || isValidColorValue(value);
|
|
305
|
+
if (NUMERIC_ATTRS.has(name)) return NUMERIC_RE.test(value);
|
|
306
|
+
if (COORD_LIST_ATTRS.has(name)) return COORD_LIST_RE.test(value);
|
|
307
|
+
if (GEOMETRY_PATH_ATTRS.has(name)) {
|
|
308
|
+
return (
|
|
309
|
+
/^[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]*$/.test(value) &&
|
|
310
|
+
hasOnlySingleCharAlphaRuns(value, PATH_COMMAND_LETTERS)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (GEOMETRY_POINTS_ATTRS.has(name)) {
|
|
314
|
+
return /^[0-9eE.,+\-\s]*$/.test(value); // no alphabetic character admitted at all
|
|
315
|
+
}
|
|
316
|
+
if (TRANSFORM_ATTRS.has(name)) {
|
|
317
|
+
return (
|
|
318
|
+
/^[A-Za-z0-9eE.,+\-()\s]*$/.test(value) &&
|
|
319
|
+
hasOnlyKnownFunctionNames(value, TRANSFORM_FUNCTIONS)
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (name in ENUM_ATTRS) return ENUM_ATTRS[name].has(value);
|
|
323
|
+
if (name === 'preserveAspectRatio') return PRESERVE_ASPECT_RATIO_RE.test(value);
|
|
324
|
+
if (name in NAMESPACE_ATTRS) return value === NAMESPACE_ATTRS[name];
|
|
325
|
+
if (IDENTIFIER_ATTRS.has(name)) return IDENTIFIER_RE.test(value);
|
|
326
|
+
if (FONT_FAMILY_ATTRS.has(name)) return classifyFontFamilyList(value).length > 0;
|
|
327
|
+
if (ALREADY_CONSTRAINED_ATTRS.has(name)) return true; // DDR-167 already grammar-gates these
|
|
328
|
+
return false; // fail-closed default: no assigned bucket → drop
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Recursively remove Comment nodes (no SVGO legal-comment `<!--! -->` carve-out). */
|
|
332
|
+
function stripComments(node) {
|
|
333
|
+
const children = [...node.childNodes];
|
|
334
|
+
for (const child of children) {
|
|
335
|
+
if (child.nodeType === 8 /* COMMENT_NODE */) {
|
|
336
|
+
child.remove();
|
|
337
|
+
} else if (child.nodeType === 1 /* ELEMENT_NODE */) {
|
|
338
|
+
stripComments(child);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const TEXT_ELEMENTS = new Set(['title', 'desc', 'text', 'tspan', 'textPath', 'metadata']);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Decision 6 — the `--from-brand`-logo-specific hardening pass, run on top
|
|
347
|
+
* of DDR-167's own sanitized output. Strips text-content elements + all
|
|
348
|
+
* comments; validates every retained attribute against its class grammar,
|
|
349
|
+
* dropping non-conforming values (attribute removed, element kept).
|
|
350
|
+
* Returns `{ hardened, hadWordmarkText }` — `hadWordmarkText` is true when a
|
|
351
|
+
* `<text>` element carrying non-whitespace content was stripped, signaling
|
|
352
|
+
* the raster-fallback path should run.
|
|
353
|
+
*/
|
|
354
|
+
export function hardenBrandLogoSvg(sanitizedSvgText) {
|
|
355
|
+
const window = new Window();
|
|
356
|
+
const doc = new window.DOMParser().parseFromString(sanitizedSvgText, 'image/svg+xml');
|
|
357
|
+
const root = doc.documentElement;
|
|
358
|
+
if (!root || root.tagName !== 'svg' || root.namespaceURI !== SVG_NS) {
|
|
359
|
+
throw new ImportBrandError(3, 'input is not a valid sanitized SVG document');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let hadWordmarkText = false;
|
|
363
|
+
for (const el of [...root.querySelectorAll('*')]) {
|
|
364
|
+
if (!el.isConnected) continue;
|
|
365
|
+
if (TEXT_ELEMENTS.has(el.tagName)) {
|
|
366
|
+
if (el.tagName === 'text' && (el.textContent || '').trim().length > 0) {
|
|
367
|
+
hadWordmarkText = true;
|
|
368
|
+
}
|
|
369
|
+
el.remove();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
stripComments(root);
|
|
374
|
+
|
|
375
|
+
for (const el of [...root.querySelectorAll('*'), root]) {
|
|
376
|
+
if (!el.isConnected && el !== root) continue;
|
|
377
|
+
for (const attr of [...el.attributes]) {
|
|
378
|
+
const local = attr.localName;
|
|
379
|
+
const fullName = attr.name;
|
|
380
|
+
if (local === 'xmlns' || fullName === 'xmlns:xlink') {
|
|
381
|
+
if (!isValidAttributeValue(fullName, attr.value)) el.removeAttribute(fullName);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!isValidAttributeValue(local, attr.value)) el.removeAttribute(fullName);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const hardened = root.outerHTML;
|
|
389
|
+
return { hardened, hadWordmarkText };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Decision 1 — palette + font extraction (from the HARDENED string only —
|
|
394
|
+
// every value here already passed Decision 6's grammar, so nothing further
|
|
395
|
+
// to validate; this step just collects + dedupes).
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
const PALETTE_MAX = 8;
|
|
399
|
+
const FONTS_MAX = 3;
|
|
400
|
+
|
|
401
|
+
/** Extract a deduped palette (real color values only, `none`/`currentColor` excluded) from a hardened SVG string. */
|
|
402
|
+
export function extractPalette(hardenedSvgText) {
|
|
403
|
+
const window = new Window();
|
|
404
|
+
const doc = new window.DOMParser().parseFromString(hardenedSvgText, 'image/svg+xml');
|
|
405
|
+
const root = doc.documentElement;
|
|
406
|
+
if (!root) return [];
|
|
407
|
+
const seen = new Set();
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const el of [root, ...root.querySelectorAll('*')]) {
|
|
410
|
+
for (const attrName of ['fill', 'stroke', 'stop-color']) {
|
|
411
|
+
const v = el.getAttribute?.(attrName);
|
|
412
|
+
if (!v || COLOR_KEYWORDS.has(v) || seen.has(v)) continue;
|
|
413
|
+
seen.add(v);
|
|
414
|
+
out.push(v);
|
|
415
|
+
if (out.length >= PALETTE_MAX) return out;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Extract deduped, classified font names from a hardened SVG string. */
|
|
422
|
+
export function extractFonts(hardenedSvgText) {
|
|
423
|
+
const window = new Window();
|
|
424
|
+
const doc = new window.DOMParser().parseFromString(hardenedSvgText, 'image/svg+xml');
|
|
425
|
+
const root = doc.documentElement;
|
|
426
|
+
if (!root) return [];
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
const out = [];
|
|
429
|
+
for (const el of [root, ...root.querySelectorAll('*')]) {
|
|
430
|
+
const v = el.getAttribute?.('font-family');
|
|
431
|
+
if (!v) continue;
|
|
432
|
+
for (const name of classifyFontFamilyList(v)) {
|
|
433
|
+
if (seen.has(name)) continue;
|
|
434
|
+
seen.add(name);
|
|
435
|
+
out.push(name);
|
|
436
|
+
if (out.length >= FONTS_MAX) return out;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ============================================================================
|
|
443
|
+
// Decision 6 — raster fallback for a logo whose wordmark was live `<text>`.
|
|
444
|
+
// Reuses DDR-167's existing sandboxed-render mechanism (no new dependency,
|
|
445
|
+
// no new browser automation surface). Renders the PRE-strip, already-
|
|
446
|
+
// DDR-167-sanitized SVG (never the original, never re-fetched).
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
async function rasterizeLogoWordmark(preStripSanitizedSvg, { timeoutMs = 15_000 } = {}) {
|
|
450
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-import-brand-'));
|
|
451
|
+
const svgPath = join(dir, 'logo.svg');
|
|
452
|
+
const pngPath = join(dir, 'logo.png');
|
|
453
|
+
try {
|
|
454
|
+
writeFileSync(svgPath, preStripSanitizedSvg);
|
|
455
|
+
await withSandboxedRender(
|
|
456
|
+
async (session) => {
|
|
457
|
+
session.open(`file://${svgPath}`);
|
|
458
|
+
session.screenshot(pngPath);
|
|
459
|
+
},
|
|
460
|
+
{ timeoutMs }
|
|
461
|
+
);
|
|
462
|
+
if (!existsSync(pngPath)) {
|
|
463
|
+
throw new ImportBrandError(4, 'logo wordmark raster fallback produced no output');
|
|
464
|
+
}
|
|
465
|
+
return readFileSync(pngPath);
|
|
466
|
+
} finally {
|
|
467
|
+
rmSync(dir, { recursive: true, force: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============================================================================
|
|
472
|
+
// Write-path (mirrors _import-asset.mjs's content-addressed containment)
|
|
473
|
+
// ============================================================================
|
|
474
|
+
|
|
475
|
+
function assetNameFor(bytes, ext) {
|
|
476
|
+
const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
477
|
+
const name = `${sha8}.${ext}`;
|
|
478
|
+
if (!/^[a-z0-9]{8}\.(svg|png)$/.test(name)) {
|
|
479
|
+
throw new ImportBrandError(6, `generated name failed the charset contract: ${name}`);
|
|
480
|
+
}
|
|
481
|
+
return name;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function writeLogoAsset(root, designRootRel, bytes, ext) {
|
|
485
|
+
const name = assetNameFor(bytes, ext);
|
|
486
|
+
const logosDir = resolve(root, designRootRel, 'assets', 'logos');
|
|
487
|
+
const rootAbs = resolve(root);
|
|
488
|
+
if (!logosDir.startsWith(rootAbs + sep)) {
|
|
489
|
+
throw new ImportBrandError(6, `logos dir escapes root: ${logosDir}`);
|
|
490
|
+
}
|
|
491
|
+
mkdirSync(logosDir, { recursive: true });
|
|
492
|
+
const fileAbs = resolve(logosDir, name);
|
|
493
|
+
if (!fileAbs.startsWith(logosDir + sep)) {
|
|
494
|
+
throw new ImportBrandError(6, `resolved logo path escapes logos dir: ${fileAbs}`);
|
|
495
|
+
}
|
|
496
|
+
if (!existsSync(fileAbs)) {
|
|
497
|
+
const tmp = join(
|
|
498
|
+
logosDir,
|
|
499
|
+
`.tmp-${createHash('sha256').update(name).digest('hex').slice(0, 12)}`
|
|
500
|
+
);
|
|
501
|
+
writeFileSync(tmp, bytes);
|
|
502
|
+
renameSync(tmp, fileAbs);
|
|
503
|
+
}
|
|
504
|
+
return { ref: `assets/logos/${name}`, path: fileAbs, name };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ============================================================================
|
|
508
|
+
// Orchestration
|
|
509
|
+
// ============================================================================
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Full brand-cue extraction: takes the path to an ALREADY-DDR-167-sanitized
|
|
513
|
+
* SVG asset (as produced by `maude design import-asset`) — never a raw
|
|
514
|
+
* brand-file path, never re-sanitizes, never re-runs the execution canary
|
|
515
|
+
* (Decision 2). Hardens the logo, extracts typed cues, writes the DDR-141
|
|
516
|
+
* asset(s). Returns the payload-ready `{ palette, fonts, logoRef }` plus a
|
|
517
|
+
* `logoRasterRef` when a wordmark fallback was needed.
|
|
518
|
+
*/
|
|
519
|
+
export async function importBrand({ sanitizedSvgPath, root, designRootRel = '.design' }) {
|
|
520
|
+
if (!existsSync(sanitizedSvgPath)) {
|
|
521
|
+
throw new ImportBrandError(4, `sanitized SVG asset not found: ${sanitizedSvgPath}`);
|
|
522
|
+
}
|
|
523
|
+
const originalSanitized = readFileSync(sanitizedSvgPath, 'utf8');
|
|
524
|
+
const { hardened, hadWordmarkText } = hardenBrandLogoSvg(originalSanitized);
|
|
525
|
+
|
|
526
|
+
const palette = extractPalette(hardened);
|
|
527
|
+
const fonts = extractFonts(hardened);
|
|
528
|
+
|
|
529
|
+
const logoAsset = writeLogoAsset(root, designRootRel, Buffer.from(hardened, 'utf8'), 'svg');
|
|
530
|
+
|
|
531
|
+
let logoRasterRef = null;
|
|
532
|
+
if (hadWordmarkText) {
|
|
533
|
+
const png = await rasterizeLogoWordmark(originalSanitized);
|
|
534
|
+
const rasterAsset = writeLogoAsset(root, designRootRel, png, 'png');
|
|
535
|
+
logoRasterRef = rasterAsset.ref;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
palette,
|
|
540
|
+
fonts,
|
|
541
|
+
logoRef: logoAsset.ref,
|
|
542
|
+
logoRasterRef,
|
|
543
|
+
hadWordmarkText,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// CLI entry
|
|
549
|
+
// ============================================================================
|
|
550
|
+
|
|
551
|
+
function parseArgv(argv) {
|
|
552
|
+
const out = { file: null, root: null, designRoot: '.design', json: false };
|
|
553
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
554
|
+
const a = argv[i];
|
|
555
|
+
switch (a) {
|
|
556
|
+
case '--root':
|
|
557
|
+
out.root = argv[++i];
|
|
558
|
+
break;
|
|
559
|
+
case '--design-root':
|
|
560
|
+
out.designRoot = argv[++i];
|
|
561
|
+
break;
|
|
562
|
+
case '--json':
|
|
563
|
+
out.json = true;
|
|
564
|
+
break;
|
|
565
|
+
case '--help':
|
|
566
|
+
case '-h':
|
|
567
|
+
out.help = true;
|
|
568
|
+
break;
|
|
569
|
+
default:
|
|
570
|
+
if (a.startsWith('-')) throw new ImportBrandError(2, `unknown flag ${a}`);
|
|
571
|
+
if (out.file === null) out.file = a;
|
|
572
|
+
else throw new ImportBrandError(2, `unexpected extra arg ${a}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const HELP = `import-brand — brand-file typed-cue extraction (reached via \`maude design import-brand\`)
|
|
579
|
+
|
|
580
|
+
Usage:
|
|
581
|
+
maude design import-brand <sanitized-svg-path> --root <repo> [--design-root .design] [--json]
|
|
582
|
+
|
|
583
|
+
Extracts palette + font-family cues from a LOGO SVG that has ALREADY been
|
|
584
|
+
sanitized by \`maude design import-asset\` (DDR-167) — never re-reads or
|
|
585
|
+
re-sanitizes the original brand file. Hardens the logo (strips text/comments,
|
|
586
|
+
validates every retained attribute against an exhaustive per-type grammar —
|
|
587
|
+
DDR-173 Decision 6), writes it to <designRoot>/assets/logos/<sha8>.svg, and
|
|
588
|
+
rasterizes a PNG fallback when the logo's wordmark was live text. Prints the
|
|
589
|
+
typed payload \`{ palette, fonts, logoRef, logoRasterRef }\` for the caller to
|
|
590
|
+
seed into the ux-research-agent discovery payload — see DDR-173.
|
|
591
|
+
|
|
592
|
+
Exit: 0 ok · 2 usage · 3 validation/hardening reject · 4 read/parse error ·
|
|
593
|
+
6 write/containment error · 1 other.`;
|
|
594
|
+
|
|
595
|
+
async function main() {
|
|
596
|
+
let opts;
|
|
597
|
+
try {
|
|
598
|
+
opts = parseArgv(process.argv.slice(2));
|
|
599
|
+
} catch (err) {
|
|
600
|
+
process.stderr.write(`import-brand: ${err.message}\n`);
|
|
601
|
+
process.exit(err instanceof ImportBrandError ? err.code : 2);
|
|
602
|
+
}
|
|
603
|
+
if (opts.help) {
|
|
604
|
+
process.stdout.write(`${HELP}\n`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!opts.file) {
|
|
608
|
+
process.stderr.write('import-brand: <sanitized-svg-path> required\n');
|
|
609
|
+
process.exit(2);
|
|
610
|
+
}
|
|
611
|
+
const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
612
|
+
try {
|
|
613
|
+
const result = await importBrand({
|
|
614
|
+
sanitizedSvgPath: opts.file,
|
|
615
|
+
root,
|
|
616
|
+
designRootRel: opts.designRoot,
|
|
617
|
+
});
|
|
618
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
process.stderr.write(`import-brand: ${err.message}\n`);
|
|
621
|
+
process.exit(err instanceof ImportBrandError ? err.code : 1);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// import.meta.main is the reliable "am I the entry module?" flag under bun
|
|
626
|
+
// --compile — the argv/url compare below falsely matches inside a standalone
|
|
627
|
+
// binary (every bundled module's import.meta.url collapses to the binary's
|
|
628
|
+
// own path), which would hijack the process before Bun.serve ever runs (the
|
|
629
|
+
// v0.38.0 "Starting…" hang class of bug). Fall back to the argv compare only
|
|
630
|
+
// for the plain-`node` CLI path (Node <24 leaves import.meta.main undefined).
|
|
631
|
+
const runDirectly =
|
|
632
|
+
import.meta.main ?? (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
|
|
633
|
+
if (runDirectly) {
|
|
634
|
+
main();
|
|
635
|
+
}
|