slidemind 0.0.27

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,796 @@
1
+ // Slide lint pass — enforces the typed authoring contract.
2
+ //
3
+ // Runs over every slide .jsx in a deck before any HTML/PPTX rendering.
4
+ // Errors fail the build with file:line; warnings print but don't fail.
5
+ // All checks together aim to close the gap between author CSS/JSX and
6
+ // PPTX-translatable output, so regressions surface at build time with a
7
+ // clear suggested replacement instead of as silent visual drift.
8
+ //
9
+ // Five sections, in order:
10
+ // 1. Standard-techniques (AST): imports, hooks, event handlers,
11
+ // dangerouslySetInnerHTML outside <style>, banned HTML elements,
12
+ // custom-element tags, unknown PascalCase components.
13
+ // 2. Layer 0 deny-list (CSS + JSX): properties the converter cannot
14
+ // honor; remote img URLs (local svg/raster <img> is fine — the
15
+ // server render and the HTML preview both inline local assets).
16
+ // 3. Layer 1 token discipline (CSS values): hex/rgb/named colors,
17
+ // disallowed font-family. Raw px/em/rem are warn-for-now during
18
+ // rollout; flip to error after a deck cleanup pass.
19
+ // 4. Primitive registry (AST): every PascalCase JSX tag must be in
20
+ // skills/create-slides/primitives.json.
21
+ // 5. PPTX-fidelity contract (the rules that prevent HTML↔PPTX
22
+ // divergence — see references/authoring-contract.md): pseudo
23
+ // elements may not carry layout content; SVG <text> may not carry
24
+ // multi-word labels; one-line actionTitle must fit Arial's ~55-char
25
+ // budget. Each rule maps 1:1 to a "Common mistakes to avoid" entry.
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ import { parse } from "@babel/parser";
32
+ import _traverse from "@babel/traverse";
33
+ const traverse = _traverse.default || _traverse;
34
+
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = path.dirname(__filename);
37
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
38
+ // The layout-contract registry now ships alongside the builders (the per-CD
39
+ // design lives in the materialized bundle). Callers in the build pipeline pass
40
+ // the resolved bundle/deck primitives explicitly; this is the fallback.
41
+ const DEFAULT_PRIMITIVES_PATH = path.join(__dirname, "primitives.json");
42
+
43
+ const _registryCache = new Map();
44
+
45
+ function loadOnePrimitivesFile(primitivesPath) {
46
+ if (!primitivesPath || !fs.existsSync(primitivesPath)) {
47
+ return { layouts: [], primitives: [] };
48
+ }
49
+ const json = JSON.parse(fs.readFileSync(primitivesPath, "utf8"));
50
+ return {
51
+ layouts: json.layouts || [],
52
+ primitives: json.primitives || [],
53
+ };
54
+ }
55
+
56
+ // Derive the valid layout component names from the materialized bundle's
57
+ // `layouts/*.jsx`. The build binds each layout by its `export default function
58
+ // <Name>` (falling back to the PascalCase of the kebab filename stem), so we
59
+ // mirror that here. This lets the lint recognize the bundle's actual layout
60
+ // components WITHOUT a primitives.json `layouts` list — the materialized bundle
61
+ // doesn't ship one, and the per-CD layout set is whatever the add-in produced.
62
+ function deriveLayoutBindingsFromDirs(dirs) {
63
+ const names = new Set();
64
+ for (const dir of dirs || []) {
65
+ if (!dir || !fs.existsSync(dir)) continue;
66
+ let files;
67
+ try {
68
+ files = fs.readdirSync(dir);
69
+ } catch {
70
+ continue;
71
+ }
72
+ for (const f of files) {
73
+ if (!/\.jsx$/i.test(f)) continue;
74
+ let src;
75
+ try {
76
+ src = fs.readFileSync(path.join(dir, f), "utf8");
77
+ } catch {
78
+ continue;
79
+ }
80
+ const exportName = src.match(
81
+ /export\s+default\s+function\s+([A-Za-z_$][\w$]*)/,
82
+ )?.[1];
83
+ if (exportName) names.add(exportName);
84
+ const pascal = f
85
+ .replace(/\.jsx$/i, "")
86
+ .split(/[-_\s]+/)
87
+ .filter(Boolean)
88
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
89
+ .join("");
90
+ if (pascal) names.add(pascal);
91
+ }
92
+ }
93
+ return names;
94
+ }
95
+
96
+ // Load and merge one or more primitives.json files plus the layout component
97
+ // names derived from the materialized layout dirs. The active skill's
98
+ // primitives take precedence over the brand's on name conflict (rare).
99
+ // Pass either a string path or { primitivesPath, brandPrimitivesPath,
100
+ // layoutsDirs } — the cache key covers all inputs so each combination is
101
+ // memoised once.
102
+ function loadRegistry(arg = DEFAULT_PRIMITIVES_PATH) {
103
+ const paths =
104
+ typeof arg === "string"
105
+ ? [arg]
106
+ : [arg.brandPrimitivesPath, arg.primitivesPath].filter(
107
+ (p, i, a) => p && a.indexOf(p) === i,
108
+ );
109
+ if (paths.length === 0) paths.push(DEFAULT_PRIMITIVES_PATH);
110
+ const layoutsDirs =
111
+ typeof arg === "object" && arg && Array.isArray(arg.layoutsDirs)
112
+ ? arg.layoutsDirs.filter(Boolean)
113
+ : [];
114
+ const cacheKey = [...paths, "::layouts::", ...layoutsDirs].join("\n");
115
+ if (_registryCache.has(cacheKey)) return _registryCache.get(cacheKey);
116
+ const layouts = new Set();
117
+ const primitives = new Map();
118
+ for (const p of paths) {
119
+ const file = loadOnePrimitivesFile(p);
120
+ for (const name of file.layouts) layouts.add(name);
121
+ for (const prim of file.primitives) primitives.set(prim.name, prim);
122
+ }
123
+ for (const name of deriveLayoutBindingsFromDirs(layoutsDirs)) {
124
+ layouts.add(name);
125
+ }
126
+ const reg = { layouts, primitives };
127
+ _registryCache.set(cacheKey, reg);
128
+ return reg;
129
+ }
130
+
131
+ const BANNED_HOOKS = new Set([
132
+ "useState",
133
+ "useEffect",
134
+ "useRef",
135
+ "useContext",
136
+ "useMemo",
137
+ "useCallback",
138
+ "useReducer",
139
+ "useLayoutEffect",
140
+ "useImperativeHandle",
141
+ "useDebugValue",
142
+ "useTransition",
143
+ "useDeferredValue",
144
+ "useId",
145
+ "useSyncExternalStore",
146
+ ]);
147
+
148
+ const BANNED_HTML_TAGS = new Set([
149
+ "script",
150
+ "iframe",
151
+ "canvas",
152
+ "object",
153
+ "embed",
154
+ ]);
155
+
156
+ const BANNED_CSS_PROPERTIES = [
157
+ "filter",
158
+ "mask",
159
+ "mask-image",
160
+ "backdrop-filter",
161
+ "mix-blend-mode",
162
+ "animation",
163
+ "animation-name",
164
+ "animation-duration",
165
+ "transition",
166
+ "transition-property",
167
+ "content-visibility",
168
+ ];
169
+
170
+ const HEX_COLOR_RE = /#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
171
+ const RGB_COLOR_RE = /\b(?:rgb|rgba|hsl|hsla)\(/gi;
172
+
173
+ // CSS named colors that we want to flag (subset; covers the common ones an
174
+ // agent might reach for). transparent / currentColor / inherit / unset are
175
+ // in ALLOWED_COLOR_KEYWORDS.
176
+ const NAMED_COLORS_RE = /\b(?:black|white|gray|grey|red|green|blue|yellow|orange|purple|pink|brown|silver|gold|cyan|magenta|lime|teal|navy|maroon|olive|aqua|fuchsia)\b/gi;
177
+
178
+ function camelToKebab(name) {
179
+ return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
180
+ }
181
+
182
+ function isPascalCase(name) {
183
+ return typeof name === "string" && /^[A-Z]/.test(name);
184
+ }
185
+
186
+ function jsxNameToString(node) {
187
+ if (!node) return "";
188
+ if (node.type === "JSXIdentifier") return node.name;
189
+ if (node.type === "JSXMemberExpression") {
190
+ return jsxNameToString(node.object) + "." + jsxNameToString(node.property);
191
+ }
192
+ return "";
193
+ }
194
+
195
+ // Collect issues from one slide and return them. Each issue is
196
+ // { severity: "error"|"warn", line, message }.
197
+ function lintSlideSource(src, fileName, registrySource) {
198
+ const issues = [];
199
+ const registry = loadRegistry(registrySource);
200
+
201
+ const reportLine = (loc) => (loc && loc.start ? loc.start.line : 0);
202
+
203
+ function err(loc, message) {
204
+ issues.push({ severity: "error", line: reportLine(loc), message });
205
+ }
206
+ function warn(loc, message) {
207
+ issues.push({ severity: "warn", line: reportLine(loc), message });
208
+ }
209
+
210
+ let ast;
211
+ try {
212
+ ast = parse(src, {
213
+ sourceType: "module",
214
+ plugins: ["jsx"],
215
+ });
216
+ } catch (e) {
217
+ issues.push({
218
+ severity: "error",
219
+ line: e.loc ? e.loc.line : 0,
220
+ message: `parse error: ${e.message}`,
221
+ });
222
+ return issues;
223
+ }
224
+
225
+ // Locally-declared PascalCase functions count as registered components
226
+ // for this file. Slides occasionally define small JSX helpers inline
227
+ // — at top level OR nested inside the default-exported render function
228
+ // (e.g. <Spark> inside 13-roi.jsx is declared as a const inside the
229
+ // slide's render function). Scan the whole AST.
230
+ const localComponents = new Set();
231
+ traverse(ast, {
232
+ FunctionDeclaration(p) {
233
+ if (p.node.id && isPascalCase(p.node.id.name)) {
234
+ localComponents.add(p.node.id.name);
235
+ }
236
+ },
237
+ VariableDeclarator(p) {
238
+ const id = p.node.id;
239
+ const init = p.node.init;
240
+ if (
241
+ id &&
242
+ id.type === "Identifier" &&
243
+ isPascalCase(id.name) &&
244
+ init &&
245
+ (init.type === "ArrowFunctionExpression" ||
246
+ init.type === "FunctionExpression")
247
+ ) {
248
+ localComponents.add(id.name);
249
+ }
250
+ },
251
+ });
252
+
253
+ // Section 3: collect top-level CSS template literals (declared in the
254
+ // module scope) so we can lint them like style sheets. The convention
255
+ // is `const SLIDE_CSS = \`...\`;` followed by `<style dangerouslySet...>`.
256
+ const cssBlocks = []; // { value, loc }
257
+
258
+ traverse(ast, {
259
+ // 1a. Imports / requires at module top level.
260
+ ImportDeclaration(p) {
261
+ err(
262
+ p.node.loc,
263
+ `import statements are not allowed in slide JSX (slides have no module system at runtime). Compose with the registered primitives instead.`,
264
+ );
265
+ },
266
+ CallExpression(p) {
267
+ const callee = p.node.callee;
268
+ if (
269
+ callee.type === "Identifier" &&
270
+ (callee.name === "require" || callee.name === "import")
271
+ ) {
272
+ err(
273
+ p.node.loc,
274
+ `${callee.name}() is not allowed in slide JSX. Slides cannot pull in packages.`,
275
+ );
276
+ }
277
+ // 1c. React hooks.
278
+ if (callee.type === "Identifier" && BANNED_HOOKS.has(callee.name)) {
279
+ err(
280
+ p.node.loc,
281
+ `${callee.name} is not allowed in slide JSX. Slides are stateless render functions; no React hooks.`,
282
+ );
283
+ }
284
+ },
285
+ JSXOpeningElement(p) {
286
+ const tagName = jsxNameToString(p.node.name);
287
+ const isPascal = isPascalCase(tagName);
288
+ const isDom = !isPascal && tagName !== "";
289
+
290
+ // 1d. Banned HTML tags.
291
+ if (isDom && BANNED_HTML_TAGS.has(tagName)) {
292
+ err(
293
+ p.node.loc,
294
+ `<${tagName}> is not allowed in slide JSX. Use static markup or a registered primitive.`,
295
+ );
296
+ }
297
+
298
+ // 1e. Custom-element tag names (contain a hyphen) outside the registry.
299
+ if (
300
+ isDom &&
301
+ tagName.includes("-") &&
302
+ !registry.layouts.has(tagName) &&
303
+ !registry.primitives.has(tagName)
304
+ ) {
305
+ err(
306
+ p.node.loc,
307
+ `<${tagName}> looks like a custom element. Custom elements / web components are not supported by the PPTX exporter.`,
308
+ );
309
+ }
310
+
311
+ // 4. Primitive registry: every PascalCase tag must be registered
312
+ // OR declared locally in this same slide file.
313
+ if (isPascal) {
314
+ if (
315
+ !registry.layouts.has(tagName) &&
316
+ !registry.primitives.has(tagName) &&
317
+ !localComponents.has(tagName)
318
+ ) {
319
+ err(
320
+ p.node.loc,
321
+ `<${tagName}> is not in primitives.json and is not a local function in this file. Add it to the registry or use a registered primitive and a layout component.`,
322
+ );
323
+ }
324
+ }
325
+
326
+ // 1b. Event handlers and dangerouslySetInnerHTML.
327
+ for (const attr of p.node.attributes) {
328
+ if (attr.type !== "JSXAttribute") continue;
329
+ const attrName =
330
+ attr.name && attr.name.type === "JSXIdentifier"
331
+ ? attr.name.name
332
+ : attr.name && attr.name.type === "JSXNamespacedName"
333
+ ? attr.name.namespace.name + ":" + attr.name.name.name
334
+ : "";
335
+
336
+ if (attrName.startsWith("on") && /^on[A-Z]/.test(attrName)) {
337
+ err(
338
+ attr.loc,
339
+ `event handler ${attrName} is not allowed. Slides are static; event handlers don't translate to the .pptx render.`,
340
+ );
341
+ }
342
+
343
+ if (attrName === "dangerouslySetInnerHTML" && tagName !== "style") {
344
+ err(
345
+ attr.loc,
346
+ `dangerouslySetInnerHTML is only allowed on <style> elements. Compose with JSX children instead.`,
347
+ );
348
+ }
349
+
350
+ // 2. <img> source checks. Local svg/raster assets are fine — both
351
+ // the server render and the HTML preview inline local files. Only
352
+ // remote URLs are rejected; PPTX export requires self-contained
353
+ // assets. Catch literal strings AND template literals.
354
+ if (tagName === "img" && attrName === "src") {
355
+ const literal = attrLiteralValue(attr);
356
+ const templateText = attrTemplateText(attr);
357
+ const text = literal != null ? literal : templateText;
358
+ if (typeof text === "string") {
359
+ if (/^https?:/i.test(text)) {
360
+ err(
361
+ attr.loc,
362
+ `<img src=remote-url> is not allowed. PPTX export requires local, self-contained assets.`,
363
+ );
364
+ }
365
+ }
366
+ }
367
+
368
+ // Inline style={{...}} props — lint as CSS values.
369
+ if (attrName === "style" && attr.value && attr.value.type === "JSXExpressionContainer") {
370
+ lintInlineStyleObject(attr.value.expression, p.node.loc, err, warn);
371
+ }
372
+
373
+ // 5c. One-line actionTitle must fit Arial Bold 40 px in the 1134 px
374
+ // slot. The HTML preview's Inter/Helvetica fallback is ~20 %
375
+ // narrower than PowerPoint's Arial, so a line that fits one
376
+ // line in the preview can wrap to two lines in PPT. Cap at
377
+ // ~55 chars when no explicit \n is present. See
378
+ // authoring-contract.md → "One-line action titles longer
379
+ // than ~55 characters".
380
+ if (attrName === "actionTitle") {
381
+ const literal = attrLiteralValue(attr);
382
+ const templateText = attrTemplateText(attr);
383
+ const text = literal != null ? literal : templateText;
384
+ if (typeof text === "string" && !text.includes("\n") && text.length > 55) {
385
+ warn(
386
+ attr.loc || p.node.loc,
387
+ `actionTitle is ${text.length} chars on a single line; may wrap to two lines in PPT (Arial Bold 40 px in the 1134 px slot fits ~55 chars). Tighten the copy, insert \\n at the intended break point, or accept the wrap as design intent.`,
388
+ );
389
+ }
390
+ }
391
+ }
392
+
393
+ // 5b. SVG <text> for multi-word labels — must be HTML over the SVG.
394
+ // authoring-contract.md → "SVG <text> for multi-word labels".
395
+ if (tagName === "text") {
396
+ // Walk the parent chain to see if this <text> sits inside an <svg>.
397
+ let cur = p.parentPath;
398
+ let insideSvg = false;
399
+ while (cur) {
400
+ if (cur.node && cur.node.type === "JSXElement") {
401
+ const parentTag = jsxNameToString(cur.node.openingElement.name);
402
+ if (parentTag === "svg") {
403
+ insideSvg = true;
404
+ break;
405
+ }
406
+ }
407
+ cur = cur.parentPath;
408
+ }
409
+ if (insideSvg) {
410
+ // Pull static text out of children: JSXText nodes + StringLiteral
411
+ // expression containers. Skip dynamic expressions — we can't
412
+ // measure their length statically.
413
+ const jsxEl = p.parentPath && p.parentPath.node && p.parentPath.node.type === "JSXElement"
414
+ ? p.parentPath.node
415
+ : null;
416
+ let textContent = "";
417
+ if (jsxEl) {
418
+ for (const child of jsxEl.children || []) {
419
+ if (child.type === "JSXText") textContent += child.value;
420
+ else if (
421
+ child.type === "JSXExpressionContainer" &&
422
+ child.expression.type === "StringLiteral"
423
+ ) {
424
+ textContent += child.expression.value;
425
+ }
426
+ }
427
+ }
428
+ const trimmed = textContent.trim();
429
+ if (trimmed.length > 4) {
430
+ warn(
431
+ p.node.loc,
432
+ `<text> inside <svg> carries ${trimmed.length} chars ("${trimmed.slice(0, 30)}${trimmed.length > 30 ? "…" : ""}"). SVG <text> projects through a different coordinate transform than <rect>/<line>/<path>, so multi-word labels decouple from the geometry in PPT. Use an HTML element (<div>/<span>) absolutely positioned over the SVG container instead. SVG <text> is reserved for single-character markers.`,
433
+ );
434
+ }
435
+ }
436
+ }
437
+ },
438
+ // 1f. Capture `<style>{`...`}</style>` template-literal CSS blocks for
439
+ // section 3 linting after the AST walk.
440
+ JSXElement(p) {
441
+ const opener = p.node.openingElement;
442
+ if (jsxNameToString(opener.name) !== "style") return;
443
+ // Look for dangerouslySetInnerHTML={{ __html: `...css...` }} OR a
444
+ // template-literal child.
445
+ for (const attr of opener.attributes) {
446
+ if (
447
+ attr.type === "JSXAttribute" &&
448
+ attr.name &&
449
+ attr.name.name === "dangerouslySetInnerHTML" &&
450
+ attr.value &&
451
+ attr.value.type === "JSXExpressionContainer" &&
452
+ attr.value.expression.type === "ObjectExpression"
453
+ ) {
454
+ for (const prop of attr.value.expression.properties) {
455
+ if (
456
+ prop.type === "ObjectProperty" &&
457
+ prop.key &&
458
+ ((prop.key.name === "__html") || (prop.key.value === "__html")) &&
459
+ prop.value
460
+ ) {
461
+ const css = expressionToString(prop.value);
462
+ if (css != null) cssBlocks.push({ value: css, loc: prop.value.loc });
463
+ }
464
+ }
465
+ }
466
+ }
467
+ for (const child of p.node.children) {
468
+ if (child.type === "JSXExpressionContainer") {
469
+ const css = expressionToString(child.expression);
470
+ if (css != null) cssBlocks.push({ value: css, loc: child.loc });
471
+ }
472
+ if (child.type === "JSXText") {
473
+ // Ignore whitespace-only text.
474
+ }
475
+ }
476
+ },
477
+ // Default-export must be a function returning JSX. We don't enforce a
478
+ // single export shape strictly — the existing slide convention is one
479
+ // default-exported function, and the rest of the build assumes that.
480
+ });
481
+
482
+ // Section 2 + 3 on collected CSS blocks.
483
+ for (const { value, loc } of cssBlocks) {
484
+ lintCssBody(value, loc, err, warn);
485
+ }
486
+
487
+ // Module-level `const X = \`...\`` template literals: lint every one as
488
+ // if it were CSS. The convention is `const SLIDE_CSS = \`...\``, but
489
+ // authors sometimes use a different name. False-positive risk is low
490
+ // because the CSS-declaration regex only matches `prop: value;` shapes.
491
+ ast.program.body.forEach((node) => {
492
+ if (node.type !== "VariableDeclaration") return;
493
+ for (const decl of node.declarations) {
494
+ if (
495
+ decl.init &&
496
+ decl.init.type === "TemplateLiteral" &&
497
+ decl.init.quasis.length >= 1
498
+ ) {
499
+ const body = decl.init.quasis.map((q) => q.value.cooked).join("");
500
+ if (/[a-z-]+\s*:\s*[^;}\n]+/i.test(body)) {
501
+ lintCssBody(body, decl.init.loc, err, warn);
502
+ }
503
+ }
504
+ }
505
+ });
506
+
507
+ return issues;
508
+ }
509
+
510
+ // Read an attribute value literal if it's a string literal.
511
+ function attrLiteralValue(attr) {
512
+ if (!attr.value) return null;
513
+ if (attr.value.type === "StringLiteral") return attr.value.value;
514
+ if (
515
+ attr.value.type === "JSXExpressionContainer" &&
516
+ attr.value.expression.type === "StringLiteral"
517
+ ) {
518
+ return attr.value.expression.value;
519
+ }
520
+ return null;
521
+ }
522
+
523
+ // Read the static text of a template-literal attribute value (the cooked
524
+ // concatenation of all quasis, ignoring expressions). Returns null if the
525
+ // attribute value isn't a template literal.
526
+ function attrTemplateText(attr) {
527
+ if (
528
+ !attr.value ||
529
+ attr.value.type !== "JSXExpressionContainer" ||
530
+ attr.value.expression.type !== "TemplateLiteral"
531
+ ) {
532
+ return null;
533
+ }
534
+ return attr.value.expression.quasis.map((q) => q.value.cooked).join("");
535
+ }
536
+
537
+ // Best-effort string extraction: literal strings and template literals
538
+ // without expressions.
539
+ function expressionToString(node) {
540
+ if (!node) return null;
541
+ if (node.type === "StringLiteral") return node.value;
542
+ if (node.type === "TemplateLiteral") {
543
+ return node.quasis.map((q) => q.value.cooked).join("");
544
+ }
545
+ if (node.type === "Identifier") {
546
+ // Bare identifier — caller resolves by walking the program if it cares.
547
+ return null;
548
+ }
549
+ return null;
550
+ }
551
+
552
+ function lintInlineStyleObject(node, loc, err, warn) {
553
+ if (!node || node.type !== "ObjectExpression") return;
554
+ for (const prop of node.properties) {
555
+ if (prop.type !== "ObjectProperty") continue;
556
+ let key = "";
557
+ if (prop.key.type === "Identifier") key = camelToKebab(prop.key.name);
558
+ else if (prop.key.type === "StringLiteral") key = prop.key.value;
559
+ if (!key) continue;
560
+
561
+ if (BANNED_CSS_PROPERTIES.includes(key)) {
562
+ err(
563
+ prop.loc || loc,
564
+ `inline style property "${key}" is on the deny-list. ${denyListSuggestion(key)}`,
565
+ );
566
+ continue;
567
+ }
568
+
569
+ const value =
570
+ prop.value.type === "StringLiteral"
571
+ ? prop.value.value
572
+ : prop.value.type === "TemplateLiteral"
573
+ ? prop.value.quasis.map((q) => q.value.cooked).join("")
574
+ : prop.value.type === "NumericLiteral"
575
+ ? String(prop.value.value)
576
+ : null;
577
+ if (value == null) continue;
578
+
579
+ checkCssValue(key, value, prop.loc || loc, err, warn);
580
+ }
581
+ }
582
+
583
+ function lintCssBody(css, loc, err, warn) {
584
+ // Strip /* comments */ to avoid false positives.
585
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
586
+
587
+ // 2a. Banned at-rules.
588
+ if (/@keyframes\b/i.test(stripped)) {
589
+ err(loc, `@keyframes is on the deny-list (animations don't translate to PPTX). Remove the animation.`);
590
+ }
591
+ if (/@container\b/i.test(stripped)) {
592
+ err(loc, `@container queries are on the deny-list. Use brand layouts to handle different aspect ratios.`);
593
+ }
594
+
595
+ // 5a. Pseudo-elements may not carry layout-bearing content.
596
+ // authoring-contract.md → "::before / ::after used for layout-bearing content".
597
+ // Walk every block selector ending in ::before / ::after; flag based
598
+ // on the block's content/width/height declarations and the pseudo's
599
+ // own position. The browser has no JS API to report the rendered
600
+ // bbox of a flex/grid-positioned pseudo, so the PPTX exporter
601
+ // cannot place it correctly.
602
+ const ruleRe = /([^{}]*?)\{([^{}]*)\}/g;
603
+ let rm;
604
+ while ((rm = ruleRe.exec(stripped)) !== null) {
605
+ const selector = rm[1].trim();
606
+ const block = rm[2];
607
+ if (!/::(?:before|after)\b/.test(selector)) continue;
608
+
609
+ const contentMatch = block.match(/\bcontent\s*:\s*("([^"]*)"|'([^']*)'|none)\s*[;}]?/i);
610
+ if (!contentMatch) continue; // No content declared → no rendered pseudo, skip.
611
+ const contentText = contentMatch[2] != null ? contentMatch[2] : contentMatch[3] != null ? contentMatch[3] : "";
612
+ const hasContent = contentMatch[0] && !/\bnone\b/i.test(contentMatch[0]);
613
+
614
+ if (hasContent && contentText.trim().length > 0) {
615
+ warn(
616
+ loc,
617
+ `${selector} declares content: "${contentText}". Pseudo-elements with semantic text content do not export to PPTX — use a real DOM element (e.g. <span aria-hidden="true">${contentText}</span>) instead.`,
618
+ );
619
+ continue;
620
+ }
621
+
622
+ // Empty content with a sized box → layout-bearing pseudo (bullet marker,
623
+ // divider). The browser flex/grid layout places it, but there is no JS
624
+ // API to read the resolved bbox, so the PPTX exporter collapses it to
625
+ // the parent's top-left.
626
+ const hasWidth = /\bwidth\s*:/.test(block) || /\bflex\s*:\s*[^;{}]*\d/.test(block);
627
+ const hasHeight = /\bheight\s*:/.test(block);
628
+ const hasPositionAbs = /\bposition\s*:\s*(?:absolute|fixed)\b/.test(block);
629
+ if ((hasWidth || hasHeight) && !hasPositionAbs) {
630
+ warn(
631
+ loc,
632
+ `${selector} is a sized pseudo-element that participates in flex/grid flow. The PPTX exporter cannot read the flex-resolved position of a pseudo, so the shape will be placed at the parent's top-left and overlap sibling text. Use a real DOM element (e.g. <span className="…__bullet" aria-hidden="true" />) inside the JSX instead, or make the pseudo position: absolute with concrete offsets.`,
633
+ );
634
+ }
635
+ }
636
+
637
+ // 2b. Banned properties — scan declarations.
638
+ const declRe = /(^|[\s;{])([a-zA-Z-]+)\s*:\s*([^;{}]+)(;|$|})/g;
639
+ let m;
640
+ while ((m = declRe.exec(stripped)) !== null) {
641
+ const prop = m[2].toLowerCase();
642
+ const value = m[3].trim();
643
+ if (BANNED_CSS_PROPERTIES.includes(prop)) {
644
+ err(loc, `CSS property "${prop}" is on the deny-list. ${denyListSuggestion(prop)}`);
645
+ continue;
646
+ }
647
+ if (prop === "position" && /\bsticky\b/.test(value)) {
648
+ err(loc, `position: sticky is on the deny-list. Sticky positioning has no PPTX equivalent.`);
649
+ continue;
650
+ }
651
+ if (prop === "clip-path" && !/^\s*inset\(/i.test(value) && !/^\s*none\s*$/i.test(value)) {
652
+ err(loc, `clip-path "${value}" is on the deny-list (only inset(...) is supported).`);
653
+ continue;
654
+ }
655
+ checkCssValue(prop, value, loc, err, warn);
656
+ }
657
+
658
+ // 2c. conic-gradient anywhere in any value.
659
+ if (/conic-gradient\(/i.test(stripped)) {
660
+ err(loc, `conic-gradient is not supported by the PPTX exporter (use linear-gradient via a brand wash token).`);
661
+ }
662
+ }
663
+
664
+ // Properties where a hex/rgb literal is worth flagging as a token-discipline
665
+ // nudge. We deliberately exclude SVG paint properties (fill, stroke, stop-color)
666
+ // — inline SVG content uses raw color values legitimately and isn't part of
667
+ // the surface-color contract.
668
+ const SURFACE_COLOR_PROPS = new Set([
669
+ "color",
670
+ "background",
671
+ "background-color",
672
+ "border-color",
673
+ "border-top-color",
674
+ "border-right-color",
675
+ "border-bottom-color",
676
+ "border-left-color",
677
+ "outline-color",
678
+ ]);
679
+
680
+ function checkCssValue(prop, value, loc, err, warn) {
681
+ // Layer 0 value checks that depend on the property + value combo.
682
+ if (prop === "position" && /\bsticky\b/.test(value)) {
683
+ err(loc, `position: sticky is on the deny-list. Sticky positioning has no PPTX equivalent.`);
684
+ }
685
+ if (prop === "clip-path" && !/^\s*inset\(/i.test(value) && !/^\s*none\s*$/i.test(value)) {
686
+ err(loc, `clip-path "${value}" is on the deny-list (only inset(...) is supported).`);
687
+ }
688
+ if (/conic-gradient\(/i.test(value)) {
689
+ err(loc, `${prop}: conic-gradient is not supported by the PPTX exporter (use linear-gradient via a brand wash token).`);
690
+ }
691
+
692
+ // Token-discipline warnings: only on surface-color properties. Sizes
693
+ // (font-size, padding, margin, grid tracks, border widths, letter-spacing,
694
+ // …) are deliberately not enforced — slides need pixel-precise authoring
695
+ // for grid layouts and micro-typography, and forcing tokens on every
696
+ // length is more noise than signal.
697
+ if (!SURFACE_COLOR_PROPS.has(prop)) return;
698
+
699
+ // Strip var(--token) expressions before scanning for color literals —
700
+ // otherwise "blue" inside `var(--sw-tech-blue)` matches the named-colors
701
+ // regex. Tokens are the right answer; this gives them a free pass.
702
+ const valueStripped = value.replace(/var\(\s*--[a-z0-9-]+\s*\)/gi, "");
703
+
704
+ if (HEX_COLOR_RE.test(valueStripped)) {
705
+ HEX_COLOR_RE.lastIndex = 0;
706
+ warn(
707
+ loc,
708
+ `${prop}: ${value} contains a hex color literal. Prefer a brand token (var(--sw-*) / var(--fg-*) / var(--bg-*)).`,
709
+ );
710
+ } else {
711
+ HEX_COLOR_RE.lastIndex = 0;
712
+ }
713
+ if (RGB_COLOR_RE.test(valueStripped)) {
714
+ RGB_COLOR_RE.lastIndex = 0;
715
+ warn(
716
+ loc,
717
+ `${prop}: ${value} contains an rgb()/hsl() color literal. Prefer a brand token (var(--sw-*) / var(--fg-*) / var(--bg-*)).`,
718
+ );
719
+ } else {
720
+ RGB_COLOR_RE.lastIndex = 0;
721
+ }
722
+ if (NAMED_COLORS_RE.test(valueStripped)) {
723
+ NAMED_COLORS_RE.lastIndex = 0;
724
+ warn(
725
+ loc,
726
+ `${prop}: ${value} uses a named CSS color. Prefer a brand token (var(--sw-*) / var(--fg-*) / var(--bg-*)).`,
727
+ );
728
+ } else {
729
+ NAMED_COLORS_RE.lastIndex = 0;
730
+ }
731
+ }
732
+
733
+ function denyListSuggestion(prop) {
734
+ switch (prop) {
735
+ case "filter":
736
+ case "backdrop-filter":
737
+ return `Inline an SVG instead of recolouring/blurring with CSS.`;
738
+ case "mask":
739
+ case "mask-image":
740
+ return `mask-image has no OOXML equivalent. Bake the mask into the SVG asset instead.`;
741
+ case "mix-blend-mode":
742
+ return `mix-blend-mode has no OOXML equivalent.`;
743
+ case "animation":
744
+ case "animation-name":
745
+ case "animation-duration":
746
+ case "transition":
747
+ case "transition-property":
748
+ return `Slides are static frames — no animation/transition.`;
749
+ case "content-visibility":
750
+ return `content-visibility has no PPTX equivalent.`;
751
+ default:
752
+ return `Use a registered primitive or a brand token instead.`;
753
+ }
754
+ }
755
+
756
+ // Run the linter over a list of slide files. Returns
757
+ // { errors, warnings, ok } where ok = true iff no errors.
758
+ export function lintSlides(slideFiles, opts = {}) {
759
+ const registrySource = {
760
+ primitivesPath: opts.primitivesPath,
761
+ brandPrimitivesPath: opts.brandPrimitivesPath,
762
+ layoutsDirs: opts.layoutsDirs,
763
+ };
764
+ let errors = 0;
765
+ let warnings = 0;
766
+ const lines = [];
767
+ for (const file of slideFiles) {
768
+ const stem = path.basename(file);
769
+ const src = fs.readFileSync(file, "utf8");
770
+ const issues = lintSlideSource(src, stem, registrySource);
771
+ for (const issue of issues) {
772
+ if (issue.severity === "error") errors++;
773
+ else warnings++;
774
+ lines.push(
775
+ ` [lint:${issue.severity}] ${stem}:${issue.line} — ${issue.message}`,
776
+ );
777
+ }
778
+ }
779
+ return { errors, warnings, lines, ok: errors === 0 };
780
+ }
781
+
782
+ // Convenience: print the lint result to stderr and throw if errors > 0.
783
+ export function lintSlidesOrThrow(slideFiles, opts = {}) {
784
+ const result = lintSlides(slideFiles, opts);
785
+ for (const line of result.lines) {
786
+ process.stderr.write(line + "\n");
787
+ }
788
+ if (!result.ok) {
789
+ throw new Error(
790
+ `slide lint: ${result.errors} error${result.errors === 1 ? "" : "s"}` +
791
+ (result.warnings ? `, ${result.warnings} warning${result.warnings === 1 ? "" : "s"}` : "") +
792
+ ". See [lint:error] lines above.",
793
+ );
794
+ }
795
+ return result;
796
+ }