@nowline/renderer 0.2.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.
@@ -0,0 +1,395 @@
1
+ // In-house SVG sanitizer. Every embedded logo flows through this pass before
2
+ // being inlined. The sanitizer is an allow-list walker — unknown elements or
3
+ // attributes are stripped, not passed through. Zero runtime dependencies
4
+ // (the renderer must be browser-safe).
5
+
6
+ // Allow-list of SVG elements we're willing to inline. No <script>, no
7
+ // <foreignObject>, no animation elements (they can leak time-based
8
+ // variance into deterministic snapshots).
9
+ const ALLOWED_ELEMENTS = new Set([
10
+ 'svg',
11
+ 'g',
12
+ 'defs',
13
+ 'symbol',
14
+ 'use',
15
+ 'path',
16
+ 'rect',
17
+ 'circle',
18
+ 'ellipse',
19
+ 'line',
20
+ 'polyline',
21
+ 'polygon',
22
+ 'text',
23
+ 'tspan',
24
+ 'title',
25
+ 'desc',
26
+ 'linearGradient',
27
+ 'radialGradient',
28
+ 'stop',
29
+ 'clipPath',
30
+ 'mask',
31
+ 'pattern',
32
+ 'filter',
33
+ 'feGaussianBlur',
34
+ 'feColorMatrix',
35
+ 'feOffset',
36
+ 'feMerge',
37
+ 'feMergeNode',
38
+ 'feFlood',
39
+ 'feComposite',
40
+ 'feBlend',
41
+ 'feDropShadow',
42
+ 'image',
43
+ ]);
44
+
45
+ // Allow-list of attributes, by-name. Event handlers (on*) are always dropped.
46
+ const ALLOWED_ATTRIBUTES = new Set([
47
+ 'd',
48
+ 'x',
49
+ 'y',
50
+ 'x1',
51
+ 'y1',
52
+ 'x2',
53
+ 'y2',
54
+ 'cx',
55
+ 'cy',
56
+ 'r',
57
+ 'rx',
58
+ 'ry',
59
+ 'width',
60
+ 'height',
61
+ 'viewBox',
62
+ 'preserveAspectRatio',
63
+ 'fill',
64
+ 'fill-opacity',
65
+ 'fill-rule',
66
+ 'stroke',
67
+ 'stroke-width',
68
+ 'stroke-opacity',
69
+ 'stroke-linecap',
70
+ 'stroke-linejoin',
71
+ 'stroke-miterlimit',
72
+ 'stroke-dasharray',
73
+ 'stroke-dashoffset',
74
+ 'opacity',
75
+ 'transform',
76
+ 'points',
77
+ 'id',
78
+ 'class',
79
+ 'clip-path',
80
+ 'mask',
81
+ 'filter',
82
+ 'font-family',
83
+ 'font-size',
84
+ 'font-weight',
85
+ 'font-style',
86
+ 'text-anchor',
87
+ 'dx',
88
+ 'dy',
89
+ 'rotate',
90
+ 'letter-spacing',
91
+ 'word-spacing',
92
+ 'style',
93
+ 'xmlns',
94
+ 'href',
95
+ 'xlink:href',
96
+ 'offset',
97
+ 'stop-color',
98
+ 'stop-opacity',
99
+ 'gradientUnits',
100
+ 'gradientTransform',
101
+ 'spreadMethod',
102
+ 'maskUnits',
103
+ 'maskContentUnits',
104
+ 'stdDeviation',
105
+ 'values',
106
+ 'in',
107
+ 'in2',
108
+ 'mode',
109
+ 'type',
110
+ 'result',
111
+ 'flood-color',
112
+ 'flood-opacity',
113
+ 'patternUnits',
114
+ 'patternContentUnits',
115
+ 'role',
116
+ 'aria-label',
117
+ ]);
118
+
119
+ export interface SanitizeOptions {
120
+ // Rewrite internal ids under this prefix to avoid collisions with the
121
+ // host document. The rewrite is both on `id=` and on references
122
+ // (`url(#...)`, `href="#..."`).
123
+ idPrefix?: string;
124
+ // Called when the sanitizer rejects a construct, for test/diagnostic use.
125
+ onWarn?: (message: string) => void;
126
+ }
127
+
128
+ interface Token {
129
+ kind: 'open' | 'close' | 'self' | 'text' | 'cdata' | 'comment' | 'decl';
130
+ name?: string;
131
+ attrs?: Record<string, string>;
132
+ text?: string;
133
+ }
134
+
135
+ // Very small, deliberately-pedantic SVG tokenizer. It understands what a
136
+ // well-formed SVG produces; it is not a full XML parser (no entity resolution,
137
+ // no DTDs). Adversarial input is rejected at the walker level rather than the
138
+ // tokenizer.
139
+ function tokenize(src: string): Token[] {
140
+ const out: Token[] = [];
141
+ let i = 0;
142
+ const n = src.length;
143
+ while (i < n) {
144
+ if (src[i] !== '<') {
145
+ // Text node
146
+ const start = i;
147
+ while (i < n && src[i] !== '<') i++;
148
+ const t = src.slice(start, i);
149
+ if (t.trim().length > 0) {
150
+ out.push({ kind: 'text', text: t });
151
+ } else if (t.length > 0) {
152
+ // Preserve whitespace between nodes to keep output stable.
153
+ out.push({ kind: 'text', text: t });
154
+ }
155
+ continue;
156
+ }
157
+ if (src.startsWith('<!--', i)) {
158
+ const end = src.indexOf('-->', i + 4);
159
+ if (end < 0) break;
160
+ out.push({ kind: 'comment', text: src.slice(i + 4, end) });
161
+ i = end + 3;
162
+ continue;
163
+ }
164
+ if (src.startsWith('<![CDATA[', i)) {
165
+ const end = src.indexOf(']]>', i + 9);
166
+ if (end < 0) break;
167
+ out.push({ kind: 'cdata', text: src.slice(i + 9, end) });
168
+ i = end + 3;
169
+ continue;
170
+ }
171
+ if (src.startsWith('<?', i)) {
172
+ const end = src.indexOf('?>', i + 2);
173
+ if (end < 0) break;
174
+ out.push({ kind: 'decl', text: src.slice(i + 2, end) });
175
+ i = end + 2;
176
+ continue;
177
+ }
178
+ if (src.startsWith('<!', i)) {
179
+ const end = src.indexOf('>', i + 2);
180
+ if (end < 0) break;
181
+ out.push({ kind: 'decl', text: src.slice(i + 2, end) });
182
+ i = end + 1;
183
+ continue;
184
+ }
185
+ if (src[i + 1] === '/') {
186
+ const end = src.indexOf('>', i + 2);
187
+ if (end < 0) break;
188
+ const name = src.slice(i + 2, end).trim();
189
+ out.push({ kind: 'close', name });
190
+ i = end + 1;
191
+ continue;
192
+ }
193
+ // Open or self-closing tag
194
+ const end = findTagEnd(src, i);
195
+ if (end < 0) break;
196
+ const body = src.slice(i + 1, end).trim();
197
+ const selfClose = body.endsWith('/');
198
+ const clean = selfClose ? body.slice(0, -1).trim() : body;
199
+ const { name, attrs } = parseTagBody(clean);
200
+ out.push({ kind: selfClose ? 'self' : 'open', name, attrs });
201
+ i = end + 1;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function findTagEnd(src: string, start: number): number {
207
+ let i = start + 1;
208
+ let inQuote: '"' | "'" | null = null;
209
+ while (i < src.length) {
210
+ const ch = src[i];
211
+ if (inQuote) {
212
+ if (ch === inQuote) inQuote = null;
213
+ } else if (ch === '"' || ch === "'") {
214
+ inQuote = ch;
215
+ } else if (ch === '>') {
216
+ return i;
217
+ }
218
+ i++;
219
+ }
220
+ return -1;
221
+ }
222
+
223
+ function parseTagBody(body: string): { name: string; attrs: Record<string, string> } {
224
+ let i = 0;
225
+ while (i < body.length && /\s/.test(body[i])) i++;
226
+ const nameStart = i;
227
+ while (i < body.length && !/\s/.test(body[i])) i++;
228
+ const name = body.slice(nameStart, i);
229
+ const attrs: Record<string, string> = {};
230
+ while (i < body.length) {
231
+ while (i < body.length && /\s/.test(body[i])) i++;
232
+ if (i >= body.length) break;
233
+ const attrStart = i;
234
+ while (i < body.length && body[i] !== '=' && !/\s/.test(body[i])) i++;
235
+ const attrName = body.slice(attrStart, i);
236
+ if (i >= body.length || body[i] !== '=') {
237
+ if (attrName) attrs[attrName] = '';
238
+ continue;
239
+ }
240
+ i++; // skip '='
241
+ if (i >= body.length) break;
242
+ const quote = body[i];
243
+ if (quote !== '"' && quote !== "'") {
244
+ const valStart = i;
245
+ while (i < body.length && !/\s/.test(body[i])) i++;
246
+ attrs[attrName] = body.slice(valStart, i);
247
+ continue;
248
+ }
249
+ i++;
250
+ const valStart = i;
251
+ while (i < body.length && body[i] !== quote) i++;
252
+ attrs[attrName] = body.slice(valStart, i);
253
+ if (i < body.length) i++; // skip closing quote
254
+ }
255
+ return { name, attrs };
256
+ }
257
+
258
+ function escAttrValue(v: string): string {
259
+ return v
260
+ .replace(/&/g, '&amp;')
261
+ .replace(/"/g, '&quot;')
262
+ .replace(/</g, '&lt;')
263
+ .replace(/>/g, '&gt;');
264
+ }
265
+
266
+ function rewriteRef(v: string, idMap: Map<string, string>): string | null {
267
+ if (v.startsWith('#')) {
268
+ const mapped = idMap.get(v.slice(1));
269
+ return mapped ? `#${mapped}` : null;
270
+ }
271
+ if (/^url\(#[^)]+\)$/.test(v)) {
272
+ const raw = v.slice(5, -1);
273
+ const mapped = idMap.get(raw);
274
+ return mapped ? `url(#${mapped})` : null;
275
+ }
276
+ // external URLs: reject
277
+ return null;
278
+ }
279
+
280
+ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): string {
281
+ const prefix = options.idPrefix ?? 'nl-logo';
282
+ const warn = options.onWarn ?? ((): void => {});
283
+ const tokens = tokenize(input);
284
+
285
+ // First pass: discover ids so the second pass can rewrite references.
286
+ const idMap = new Map<string, string>();
287
+ let idCounter = 0;
288
+ for (const t of tokens) {
289
+ if ((t.kind === 'open' || t.kind === 'self') && t.attrs && 'id' in t.attrs) {
290
+ const oldId = t.attrs.id;
291
+ if (oldId && !idMap.has(oldId)) {
292
+ idMap.set(oldId, `${prefix}-${idCounter++}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ // Second pass: emit only allow-listed elements.
298
+ const output: string[] = [];
299
+ const stack: boolean[] = []; // parallel stack of "kept" flags
300
+
301
+ for (const t of tokens) {
302
+ if (t.kind === 'comment') continue;
303
+ if (t.kind === 'decl') continue;
304
+ if (t.kind === 'cdata') {
305
+ if (stack[stack.length - 1]) output.push(escAttrValue(t.text ?? ''));
306
+ continue;
307
+ }
308
+ if (t.kind === 'text') {
309
+ if (stack.length === 0 || stack[stack.length - 1]) {
310
+ const text = (t.text ?? '')
311
+ .replace(/&/g, '&amp;')
312
+ .replace(/</g, '&lt;')
313
+ .replace(/>/g, '&gt;');
314
+ output.push(text);
315
+ }
316
+ continue;
317
+ }
318
+ if (t.kind === 'open' || t.kind === 'self') {
319
+ const name = t.name ?? '';
320
+ if (
321
+ name === 'script' ||
322
+ name === 'foreignObject' ||
323
+ name === 'animate' ||
324
+ name === 'animateTransform' ||
325
+ name === 'animateMotion' ||
326
+ name === 'set'
327
+ ) {
328
+ warn(`sanitizer: dropping <${name}>`);
329
+ if (t.kind === 'open') stack.push(false);
330
+ continue;
331
+ }
332
+ if (!ALLOWED_ELEMENTS.has(name)) {
333
+ warn(`sanitizer: dropping unknown element <${name}>`);
334
+ if (t.kind === 'open') stack.push(false);
335
+ continue;
336
+ }
337
+ const parts: string[] = [`<${name}`];
338
+ const keys = Object.keys(t.attrs ?? {}).sort();
339
+ for (const key of keys) {
340
+ const value = (t.attrs as Record<string, string>)[key];
341
+ if (/^on/i.test(key)) {
342
+ warn(`sanitizer: dropping event handler ${key}`);
343
+ continue;
344
+ }
345
+ if (!ALLOWED_ATTRIBUTES.has(key)) {
346
+ warn(`sanitizer: dropping attribute ${key}`);
347
+ continue;
348
+ }
349
+ if (key === 'href' || key === 'xlink:href') {
350
+ const rewritten = rewriteRef(value, idMap);
351
+ if (rewritten === null) {
352
+ warn(`sanitizer: dropping external href ${value}`);
353
+ continue;
354
+ }
355
+ parts.push(` ${key}="${escAttrValue(rewritten)}"`);
356
+ continue;
357
+ }
358
+ if (key === 'style') {
359
+ // CSS can smuggle `expression()` or url() — strip entirely.
360
+ warn('sanitizer: dropping inline style');
361
+ continue;
362
+ }
363
+ if (key === 'id') {
364
+ const mapped = idMap.get(value);
365
+ if (mapped) parts.push(` id="${escAttrValue(mapped)}"`);
366
+ continue;
367
+ }
368
+ // Rewrite any `url(#foo)` embedded inside fill/stroke/etc.
369
+ if (/\burl\(#/i.test(value)) {
370
+ const rewritten = value.replace(/url\(#([^)]+)\)/gi, (_m, id) => {
371
+ const mapped = idMap.get(id);
372
+ return mapped ? `url(#${mapped})` : 'none';
373
+ });
374
+ parts.push(` ${key}="${escAttrValue(rewritten)}"`);
375
+ continue;
376
+ }
377
+ // Reject raster data URIs at non-href attributes.
378
+ if (/data:|javascript:|vbscript:/i.test(value)) {
379
+ warn(`sanitizer: dropping suspicious value at ${key}`);
380
+ continue;
381
+ }
382
+ parts.push(` ${key}="${escAttrValue(value)}"`);
383
+ }
384
+ parts.push(t.kind === 'self' ? '/>' : '>');
385
+ output.push(parts.join(''));
386
+ if (t.kind === 'open') stack.push(true);
387
+ continue;
388
+ }
389
+ if (t.kind === 'close') {
390
+ const kept = stack.pop();
391
+ if (kept) output.push(`</${t.name}>`);
392
+ }
393
+ }
394
+ return output.join('');
395
+ }
@@ -0,0 +1,38 @@
1
+ import type { ShadowKind } from '@nowline/layout';
2
+ import { attrs, tag } from './xml.js';
3
+
4
+ interface Params {
5
+ dx: number;
6
+ dy: number;
7
+ stdDeviation: number;
8
+ opacity: number;
9
+ }
10
+
11
+ const PARAMS: Record<ShadowKind, Params> = {
12
+ none: { dx: 0, dy: 0, stdDeviation: 0, opacity: 0 },
13
+ subtle: { dx: 0, dy: 1, stdDeviation: 1.5, opacity: 0.2 },
14
+ soft: { dx: 0, dy: 3, stdDeviation: 5, opacity: 0.3 },
15
+ hard: { dx: 2, dy: 2, stdDeviation: 0, opacity: 0.45 },
16
+ };
17
+
18
+ export function shadowFilterDef(idPrefix: string, kind: Exclude<ShadowKind, 'none'>): string {
19
+ const id = `${idPrefix}-shadow-${kind}`;
20
+ const p = PARAMS[kind];
21
+ const inner =
22
+ `<feGaussianBlur in="SourceAlpha" stdDeviation="${p.stdDeviation}"/>` +
23
+ `<feOffset dx="${p.dx}" dy="${p.dy}" result="offsetblur"/>` +
24
+ `<feComponentTransfer><feFuncA type="linear" slope="${p.opacity}"/></feComponentTransfer>` +
25
+ `<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>`;
26
+ return tag('filter', { id, x: '-50%', y: '-50%', width: '200%', height: '200%' }, inner);
27
+ }
28
+
29
+ export function shadowFilterUrl(idPrefix: string, kind: ShadowKind): string | null {
30
+ if (kind === 'none') return null;
31
+ return `url(#${idPrefix}-shadow-${kind})`;
32
+ }
33
+
34
+ export function allShadowDefs(idPrefix: string): string {
35
+ return (['subtle', 'soft', 'hard'] as const).map((k) => shadowFilterDef(idPrefix, k)).join('');
36
+ }
37
+ // keep attrs referenced so TS doesn't complain about unused
38
+ void attrs;
package/src/svg/xml.ts ADDED
@@ -0,0 +1,58 @@
1
+ // Minimal, dependency-free XML string helpers. Every output SVG string runs
2
+ // through here; characters are escaped consistently so identical inputs
3
+ // produce identical bytes.
4
+
5
+ const AMP = /&/g;
6
+ const LT = /</g;
7
+ const GT = />/g;
8
+ const QUOTE = /"/g;
9
+ const APOS = /'/g;
10
+
11
+ export function escText(value: string): string {
12
+ return value.replace(AMP, '&amp;').replace(LT, '&lt;').replace(GT, '&gt;');
13
+ }
14
+
15
+ export function escAttr(value: string): string {
16
+ return value
17
+ .replace(AMP, '&amp;')
18
+ .replace(LT, '&lt;')
19
+ .replace(GT, '&gt;')
20
+ .replace(QUOTE, '&quot;')
21
+ .replace(APOS, '&#39;');
22
+ }
23
+
24
+ export type AttrValue = string | number | boolean | null | undefined;
25
+
26
+ export function attrs(values: Record<string, AttrValue>): string {
27
+ const keys = Object.keys(values).sort();
28
+ const parts: string[] = [];
29
+ for (const key of keys) {
30
+ const v = values[key];
31
+ if (v === null || v === undefined || v === false) continue;
32
+ if (v === true) {
33
+ parts.push(key);
34
+ } else {
35
+ parts.push(`${key}="${escAttr(String(v))}"`);
36
+ }
37
+ }
38
+ return parts.length ? ` ${parts.join(' ')}` : '';
39
+ }
40
+
41
+ export function tag(name: string, attributes: Record<string, AttrValue>, inner?: string): string {
42
+ if (inner === undefined || inner === '') {
43
+ return `<${name}${attrs(attributes)}/>`;
44
+ }
45
+ return `<${name}${attrs(attributes)}>${inner}</${name}>`;
46
+ }
47
+
48
+ export function textTag(attributes: Record<string, AttrValue>, content: string): string {
49
+ return `<text${attrs(attributes)}>${escText(content)}</text>`;
50
+ }
51
+
52
+ // Fixed-precision number formatter — avoids locale-dependent toFixed quirks
53
+ // and guarantees deterministic output across Node versions.
54
+ export function num(n: number): string {
55
+ if (!Number.isFinite(n)) return '0';
56
+ if (Number.isInteger(n)) return n.toString();
57
+ return (Math.round(n * 100) / 100).toString();
58
+ }