@lutrin/core 1.0.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,206 @@
1
+ /**
2
+ * Minimal syntax highlighting (keywords / strings / comments), shared by the
3
+ * PPTX and HTML renderers. Returns neutral segments
4
+ * `{ text, kind?, color?, bold?, italic? }` — `kind` ('keyword' | 'string' |
5
+ * 'comment') is the semantic contract: the HTML renderer relies on it for its
6
+ * classes, never on the color value (a theme can change the colors without
7
+ * breaking the highlighting). The colors stay brand design tokens, consumed
8
+ * as is by the PPTX renderer.
9
+ *
10
+ * The block's language (```js, ```python…) picks a profile: comment markers
11
+ * AND keywords specific to its family — `#097d6c` is not a comment in CSS,
12
+ * "and" is not a keyword outside Python. Strings are tokenized BEFORE
13
+ * comments: the `//` of a URL inside a string stays part of the string.
14
+ */
15
+
16
+ import { COLORS } from './tokens.mjs';
17
+
18
+ const kw = (words, flags = '') => new RegExp(`\\b(?:${words})\\b`, flags);
19
+
20
+ // Keywords by language family. Deliberately without the words that are also
21
+ // ordinary words of the everyday language outside their family (and, or, is,
22
+ // in, not, from…): better a missed keyword than prose set in bold.
23
+ const KEYWORDS = {
24
+ c: kw(
25
+ 'function|return|const|let|var|if|else|for|while|do|switch|case|break|continue|import|from|export|default|' +
26
+ 'class|extends|implements|new|await|async|try|catch|finally|throw|typeof|instanceof|static|void|this|super|' +
27
+ 'yield|delete|null|true|false|undefined|public|private|protected|interface|type|enum|namespace|readonly|' +
28
+ 'struct|fn|impl|use|mut|pub|match|func|defer|chan|package|int|string|bool|float|double|char|long',
29
+ ),
30
+ python: kw(
31
+ 'def|return|if|elif|else|for|while|import|from|as|class|with|lambda|pass|try|except|finally|raise|in|not|' +
32
+ 'and|or|is|global|nonlocal|yield|assert|del|async|await|None|True|False',
33
+ ),
34
+ shell: kw(
35
+ 'if|then|else|elif|fi|for|while|until|do|done|case|esac|function|return|exit|export|local|readonly|echo|source',
36
+ ),
37
+ ruby: kw(
38
+ 'def|end|class|module|if|elsif|else|unless|while|until|do|return|begin|rescue|ensure|yield|self|nil|true|false|require',
39
+ ),
40
+ sql: kw(
41
+ 'SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|FULL|CROSS|ON|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|INSERT|INTO|' +
42
+ 'VALUES|UPDATE|SET|DELETE|CREATE|ALTER|DROP|TABLE|INDEX|VIEW|AS|AND|OR|NOT|NULL|IN|IS|LIKE|BETWEEN|EXISTS|' +
43
+ 'UNION|DISTINCT|CASE|WHEN|THEN|ELSE|END',
44
+ 'i', // SQL keywords are also written in lowercase
45
+ ),
46
+ lua: kw(
47
+ 'function|end|if|then|else|elseif|for|while|repeat|until|do|return|local|nil|true|false|break',
48
+ ),
49
+ // unknown language: only words that are unambiguous against prose
50
+ generic: kw(
51
+ 'function|return|const|let|var|if|else|for|while|import|export|class|def|async|await|new|try|catch|null|true|false|None|True|False',
52
+ ),
53
+ };
54
+
55
+ // `#(?!\{)`: #{…} interpolation (Ruby) does not open a comment.
56
+ const HASH = /#.*$/;
57
+ const HASH_NO_INTERP = /#(?!\{).*$/;
58
+ // POSIX: `#` only opens a comment at the start of a word — `$#` (argument
59
+ // count) and `${#var}` (length) are not comments.
60
+ const HASH_SHELL = /(?<=^|\s)#(?!\{).*$/;
61
+
62
+ /** Profiles: line-comment markers + keyword set. */
63
+ const PROFILES = {
64
+ c: { comments: [/\/\/.*$/], keywords: KEYWORDS.c },
65
+ python: { comments: [HASH], keywords: KEYWORDS.python },
66
+ shell: { comments: [HASH_SHELL], keywords: KEYWORDS.shell },
67
+ ruby: { comments: [HASH_NO_INTERP], keywords: KEYWORDS.ruby },
68
+ hash: { comments: [HASH], keywords: null }, // yaml, toml, dockerfile…
69
+ sql: { comments: [/--.*$/], keywords: KEYWORDS.sql },
70
+ lua: { comments: [/--.*$/], keywords: KEYWORDS.lua },
71
+ haskell: { comments: [/--.*$/], keywords: null },
72
+ tex: { comments: [/(?<!\\)%.*$/], keywords: null }, // \% is the literal percent sign
73
+ mermaid: { comments: [/%%.*$/], keywords: null },
74
+ css: { comments: [], keywords: null }, // no line comment: #097d6c stays a color
75
+ scssline: { comments: [/\/\/.*$/], keywords: null }, // scss/less accept //
76
+ markup: { comments: [], keywords: null }, // html/xml: <!-- --> is multi-line, out of scope
77
+ // unknown language: cautious markers — `#` neither interpolation nor
78
+ // hexadecimal color; `--` only when followed by a space (never a decrement)
79
+ generic: {
80
+ comments: [/\/\/.*$/, /#(?!\{)(?![0-9a-fA-F]{3,8}\b).*$/, /--\s.*$/],
81
+ keywords: KEYWORDS.generic,
82
+ },
83
+ };
84
+
85
+ const PROFILE_BY_LANG = {
86
+ js: 'c',
87
+ jsx: 'c',
88
+ mjs: 'c',
89
+ cjs: 'c',
90
+ ts: 'c',
91
+ tsx: 'c',
92
+ javascript: 'c',
93
+ typescript: 'c',
94
+ java: 'c',
95
+ c: 'c',
96
+ h: 'c',
97
+ cpp: 'c',
98
+ 'c++': 'c',
99
+ cc: 'c',
100
+ cs: 'c',
101
+ csharp: 'c',
102
+ go: 'c',
103
+ rust: 'c',
104
+ rs: 'c',
105
+ swift: 'c',
106
+ kotlin: 'c',
107
+ kt: 'c',
108
+ scala: 'c',
109
+ php: 'c',
110
+ dart: 'c',
111
+ json: 'c',
112
+ jsonc: 'c',
113
+ python: 'python',
114
+ py: 'python',
115
+ sh: 'shell',
116
+ bash: 'shell',
117
+ zsh: 'shell',
118
+ shell: 'shell',
119
+ console: 'shell',
120
+ ruby: 'ruby',
121
+ rb: 'ruby',
122
+ yaml: 'hash',
123
+ yml: 'hash',
124
+ toml: 'hash',
125
+ ini: 'hash',
126
+ dockerfile: 'hash',
127
+ makefile: 'hash',
128
+ r: 'hash',
129
+ sql: 'sql',
130
+ lua: 'lua',
131
+ haskell: 'haskell',
132
+ hs: 'haskell',
133
+ tex: 'tex',
134
+ latex: 'tex',
135
+ mermaid: 'mermaid',
136
+ css: 'css',
137
+ scss: 'scssline',
138
+ less: 'scssline',
139
+ html: 'markup',
140
+ xml: 'markup',
141
+ svg: 'markup',
142
+ };
143
+
144
+ // Unambiguous alternation (escape OR non-backslash), lazy quantifier bounded
145
+ // by the quote: the form `(?:\\.|(?!\1).)*` blew up into exponential
146
+ // backtracking on an unterminated string full of backslashes (measured:
147
+ // 40 backslashes ≈ 1.3 s, 50 ≈ minutes — the compilation freezes).
148
+ // `[^]` (empty negated class) = "any character, line breaks included": it is
149
+ // the intended idiom, not an oversight — the linter flags it wrongly.
150
+ // biome-ignore lint/correctness/noEmptyCharacterClassInRegex: [^] is deliberate, see above
151
+ const STRING = /(["'`])(?:\\[^]|[^\\])*?\1/;
152
+
153
+ export function highlightLine(line, lang = '') {
154
+ // Object.hasOwn: PROFILE_BY_LANG is an object literal — without the guard, a
155
+ // ```constructor (or ```__proto__) block would pull up a property inherited
156
+ // from Object.prototype and crash the whole compilation
157
+ const key = String(lang ?? '').toLowerCase();
158
+ const prof = PROFILES[Object.hasOwn(PROFILE_BY_LANG, key) ? PROFILE_BY_LANG[key] : 'generic'];
159
+ const runs = [];
160
+
161
+ // text outside strings and comments: look only for keywords in it
162
+ const plain = (text) => {
163
+ while (text.length) {
164
+ const m = prof.keywords ? text.match(prof.keywords) : null;
165
+ if (!m) {
166
+ runs.push({ text });
167
+ return;
168
+ }
169
+ if (m.index > 0) runs.push({ text: text.slice(0, m.index) });
170
+ runs.push({ text: m[0], kind: 'keyword', color: COLORS.primaryDarker, bold: true });
171
+ text = text.slice(m.index + m[0].length);
172
+ }
173
+ };
174
+
175
+ let rest = line;
176
+ while (rest.length) {
177
+ const str = rest.match(STRING);
178
+ let com = null;
179
+ for (const re of prof.comments) {
180
+ const m = rest.match(re);
181
+ if (m && (com === null || m.index < com.index)) com = m;
182
+ }
183
+ // a string opened before the marker wins: the `//` of a URL inside
184
+ // "https://…" does not open a comment
185
+ if (str && (!com || str.index <= com.index)) {
186
+ if (str.index > 0) plain(rest.slice(0, str.index));
187
+ runs.push({ text: str[0], kind: 'string', color: COLORS.positiveDark });
188
+ rest = rest.slice(str.index + str[0].length);
189
+ continue;
190
+ }
191
+ if (com) {
192
+ if (com.index > 0) plain(rest.slice(0, com.index));
193
+ runs.push({
194
+ text: rest.slice(com.index),
195
+ kind: 'comment',
196
+ color: COLORS.neutralSecondary,
197
+ italic: true,
198
+ });
199
+ break;
200
+ }
201
+ plain(rest);
202
+ break;
203
+ }
204
+ if (!runs.length) runs.push({ text: '' });
205
+ return runs;
206
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Kits — a theme, its layouts and its assets as one distributable unit
3
+ * (see the "Kits" section of the README).
4
+ *
5
+ * A KIT is a directory carrying a `kit.json` manifest:
6
+ *
7
+ * my-kit/
8
+ * ├── kit.json manifest — the only mandatory file
9
+ * ├── theme.json design tokens (theme schema, see theme.mjs)
10
+ * ├── layouts/*.json layouts
11
+ * ├── fonts/*.woff2 optional
12
+ * └── logo/*.svg optional
13
+ *
14
+ * It is distributed either as is, or compressed into a `.deckkit` file (a zip
15
+ * archive). The extension describes the CONTENT — a deck kit — and not the
16
+ * tool that reads it: a format named after its tool ages badly the day some
17
+ * other tool reads it.
18
+ *
19
+ * This module knows only the manifest: reading it, validating it, and
20
+ * resolving its paths INSIDE the kit. It deliberately ignores installation, the
21
+ * zip archive and applying the theme — that is what makes it testable on its
22
+ * own, and what lets `kit install` validate a kit before writing it anywhere.
23
+ *
24
+ * Two rules carry the whole safety of the format:
25
+ *
26
+ * 1. `name` is constrained (`KIT_NAME_RE`) because it becomes a directory
27
+ * name at install time. Validating it here closes path traversal BY THE
28
+ * MANIFEST — a kit cannot name itself `../../bin`.
29
+ * 2. `theme` and `layouts` are resolved by `insideKit()`, which refuses any
30
+ * path leaving the kit. A manifest never designates a file on the host
31
+ * system.
32
+ *
33
+ * As in resolveTheme, nothing ever throws: a manifest that could not be read
34
+ * becomes an `error` diagnostic and `manifest: null`. The caller decides
35
+ * (refuse the installation, ignore the kit) — this module does not decide on
36
+ * its behalf.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { closest } from './suggest.mjs';
42
+
43
+ /** Name of the manifest, at the root of the kit. */
44
+ export const KIT_MANIFEST = 'kit.json';
45
+
46
+ /** Extension of an archived kit. */
47
+ export const KIT_EXT = '.deckkit';
48
+
49
+ /**
50
+ * Accepted kit names: lowercase letters, digits and hyphens, 64 characters at
51
+ * most, starting with an alphanumeric character.
52
+ *
53
+ * This is the name of the installation directory, so the constraint is a
54
+ * safety one before it is an aesthetic one: neither `.`, nor `/`, nor `..`
55
+ * gets through, which makes `<config>/kits/<name>` untraversable whatever the
56
+ * manifest says.
57
+ */
58
+ export const KIT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
59
+
60
+ /** Top-level keys recognized in a kit.json. */
61
+ export const KIT_KEYS = [
62
+ 'name',
63
+ 'version',
64
+ 'description',
65
+ 'author',
66
+ 'homepage',
67
+ 'theme',
68
+ 'layouts',
69
+ ];
70
+
71
+ /** Default values of the two paths — a kit that follows the convention only
72
+ * has to declare its name. */
73
+ const DEFAULT_THEME = './theme.json';
74
+ const DEFAULT_LAYOUTS = './layouts';
75
+
76
+ /** `1.0.0`, `1.0.0-beta.2` — enough to order and to display, without carrying
77
+ * a full semver parser the format has no need for. */
78
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
79
+
80
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
81
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
82
+
83
+ const isFile = (p) => {
84
+ try {
85
+ return fs.statSync(p).isFile();
86
+ } catch {
87
+ return false;
88
+ }
89
+ };
90
+ const isDir = (p) => {
91
+ try {
92
+ return fs.statSync(p).isDirectory();
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+
98
+ /**
99
+ * Does a relative path leave the kit? A purely LEXICAL verdict, with no disk:
100
+ * this is the check a manifest can undergo before the kit exists anywhere
101
+ * (an archive validated in memory). `insideKit` adds the disk to it.
102
+ *
103
+ * Windows separators are normalized first: under POSIX, `..\\..\\x` is a valid
104
+ * file name, and letting it through would turn an archive crafted under
105
+ * Windows into an escape on the other platforms.
106
+ */
107
+ export function escapesKit(rel) {
108
+ if (typeof rel !== 'string' || !rel.trim()) return true;
109
+ const norm = path.normalize(rel.replace(/\\/g, '/'));
110
+ if (path.isAbsolute(norm) || /^[A-Za-z]:/.test(norm)) return true;
111
+ return norm === '..' || norm.startsWith(`..${path.sep}`) || norm.startsWith('../');
112
+ }
113
+
114
+ /**
115
+ * Resolves `rel` under `kitDir` and refuses anything that leaves it.
116
+ *
117
+ * The symbolic link is checked AFTER resolution (realpath): without that, a
118
+ * kit could carry a link `theme.json → /etc/passwd`, whose declared path is
119
+ * nonetheless irreproachable. A link that stays inside the kit is accepted.
120
+ *
121
+ * @returns {string|null} absolute path, or null if it leaves the kit
122
+ */
123
+ export function insideKit(kitDir, rel) {
124
+ if (typeof rel !== 'string' || !rel.trim()) return null;
125
+ const root = path.resolve(kitDir);
126
+ const abs = path.resolve(root, rel);
127
+ const within = (p, base) => {
128
+ const r = path.relative(base, p);
129
+ return r === '' || (!r.startsWith('..') && !path.isAbsolute(r));
130
+ };
131
+ if (!within(abs, root)) return null;
132
+ // Comparing a realpath against a root that is not one would make every
133
+ // legitimate file look escaped as soon as the root crosses a symbolic
134
+ // link — the case of /var → /private/var under macOS, and of many a HOME.
135
+ // So both sides are dereferenced, or neither.
136
+ try {
137
+ const realRoot = fs.realpathSync(root);
138
+ // the path may not exist yet (manifest validated off disk): with no link
139
+ // to dereference, the lexical check above is enough
140
+ if (!within(fs.realpathSync(abs), realRoot)) return null;
141
+ } catch {
142
+ /* nonexistent — the caller will report the absence, not an escape */
143
+ }
144
+ return abs;
145
+ }
146
+
147
+ /**
148
+ * Validates an ALREADY parsed manifest (no disk access).
149
+ *
150
+ * Separated from `readKit` so that validating a `kit.json` extracted from an
151
+ * archive in memory — which is what `kit install` will do before writing
152
+ * anything at all — goes through exactly the same code as validating a kit on
153
+ * disk.
154
+ *
155
+ * @param {unknown} json content of the kit.json
156
+ * @param {{ where?: string }} [opts] label of the source, for the messages
157
+ * @returns {{ manifest: object|null, diagnostics: Array<{severity, code, message, suggestion?}> }}
158
+ * `manifest` is CLEANED UP: unknown keys dropped, `theme` and
159
+ * `layouts` normalized to their effective value.
160
+ */
161
+ export function parseKitManifest(json, { where = KIT_MANIFEST } = {}) {
162
+ const diags = [];
163
+ const push = (severity, code, message, suggestion) =>
164
+ diags.push({ severity, code, message, ...(suggestion ? { suggestion } : {}) });
165
+ const fail = (message) => {
166
+ push('error', 'KIT_INVALID', message);
167
+ return { manifest: null, diagnostics: diags };
168
+ };
169
+
170
+ if (!isPlainObject(json)) return fail(`Kit: ${where} must contain a JSON object.`);
171
+
172
+ // --- name: the only mandatory key, and the only one that can invalidate ----
173
+ if (typeof json.name !== 'string' || !json.name.trim())
174
+ return fail(
175
+ `Kit: ${where} must declare a "name" (non-empty string) — it is the name the kit installs under and is referenced by.`,
176
+ );
177
+ const name = json.name.trim();
178
+ if (!KIT_NAME_RE.test(name))
179
+ return fail(
180
+ `Kit: "${name}" is not an allowed name — lowercase letters, digits and hyphens, 64 characters at most, starting with a letter or a digit (e.g. "brand-acme"). The name becomes a directory at install time.`,
181
+ );
182
+
183
+ const manifest = { name, theme: DEFAULT_THEME, layouts: DEFAULT_LAYOUTS };
184
+
185
+ for (const key of Object.keys(json)) {
186
+ if (UNSAFE_KEYS.has(key)) continue;
187
+ if (!KIT_KEYS.includes(key))
188
+ push(
189
+ 'warning',
190
+ 'KIT_UNKNOWN_KEY',
191
+ `Kit: unknown key "${key}" in ${where} — ignored.`,
192
+ closest(key, KIT_KEYS) ?? undefined,
193
+ );
194
+ }
195
+
196
+ // --- version: optional, but if it is there it must be usable --------------
197
+ if (json.version != null) {
198
+ if (typeof json.version === 'string' && VERSION_RE.test(json.version.trim()))
199
+ manifest.version = json.version.trim();
200
+ else push('warning', 'KIT_BAD_VALUE', 'Kit: version must be of the form "1.0.0" — ignored.');
201
+ }
202
+
203
+ // --- display metadata -----------------------------------------------------
204
+ for (const key of ['description', 'author', 'homepage']) {
205
+ if (json[key] == null) continue;
206
+ if (typeof json[key] === 'string' && json[key].trim()) manifest[key] = json[key].trim();
207
+ else push('warning', 'KIT_BAD_VALUE', `Kit: ${key} must be a non-empty string — ignored.`);
208
+ }
209
+
210
+ // --- paths: validated for their FORM here, for existence in readKit -------
211
+ // "leaves the kit" is an error, not a warning: unlike an unknown key, it
212
+ // reveals a manifest attempting to reach the host.
213
+ for (const key of ['theme', 'layouts']) {
214
+ if (json[key] == null) continue;
215
+ if (typeof json[key] !== 'string' || !json[key].trim()) {
216
+ push(
217
+ 'warning',
218
+ 'KIT_BAD_VALUE',
219
+ `Kit: ${key} must be a relative path inside the kit (e.g. "${key === 'theme' ? DEFAULT_THEME : DEFAULT_LAYOUTS}") — default value kept.`,
220
+ );
221
+ continue;
222
+ }
223
+ const rel = json[key].trim();
224
+ if (escapesKit(rel))
225
+ return fail(
226
+ `Kit: ${where} — the path "${rel}" of ${key} leaves the kit; a manifest only designates its own files.`,
227
+ );
228
+ manifest[key] = rel;
229
+ }
230
+
231
+ return { manifest, diagnostics: diags };
232
+ }
233
+
234
+ /**
235
+ * Reads and validates the kit installed (or unpacked) in `kitDir`.
236
+ *
237
+ * To the manifest is added what only the disk can tell: the effective paths of
238
+ * `theme.json` and of `layouts/`, and the absence of both — a kit that brings
239
+ * neither design tokens nor layouts brings nothing, and reporting that at
240
+ * install time is better than discovering it at compile time.
241
+ *
242
+ * The directory name does NOT have to equal `manifest.name` here: an archive
243
+ * unpacks wherever the caller wants. It is `kit install` that installs under
244
+ * `manifest.name`, and it alone.
245
+ *
246
+ * @returns {{ manifest: object|null, dir: string, themeFile: string|null,
247
+ * layoutsDir: string|null,
248
+ * diagnostics: Array<{severity, code, message, suggestion?}> }}
249
+ */
250
+ export function readKit(kitDir) {
251
+ const dir = path.resolve(kitDir);
252
+ const bare = { manifest: null, dir, themeFile: null, layoutsDir: null };
253
+ const file = path.join(dir, KIT_MANIFEST);
254
+
255
+ if (!isDir(dir))
256
+ return {
257
+ ...bare,
258
+ diagnostics: [
259
+ {
260
+ severity: 'error',
261
+ code: 'KIT_NOT_FOUND',
262
+ message: `Kit: ${dir} is not a directory.`,
263
+ },
264
+ ],
265
+ };
266
+
267
+ let raw;
268
+ try {
269
+ raw = fs.readFileSync(file, 'utf8');
270
+ } catch {
271
+ return {
272
+ ...bare,
273
+ diagnostics: [
274
+ {
275
+ severity: 'error',
276
+ code: 'KIT_NOT_FOUND',
277
+ message: `Kit: ${KIT_MANIFEST} not found in ${dir} — a kit must carry its manifest at its root.`,
278
+ },
279
+ ],
280
+ };
281
+ }
282
+
283
+ let json;
284
+ try {
285
+ json = JSON.parse(raw);
286
+ } catch (e) {
287
+ return {
288
+ ...bare,
289
+ diagnostics: [
290
+ {
291
+ severity: 'error',
292
+ code: 'KIT_INVALID',
293
+ message: `Kit: ${file} — invalid JSON (${e?.message ?? e}).`,
294
+ },
295
+ ],
296
+ };
297
+ }
298
+
299
+ const { manifest, diagnostics } = parseKitManifest(json, { where: file });
300
+ if (!manifest) return { ...bare, diagnostics };
301
+
302
+ // insideKit can no longer return null (parseKitManifest has already refused
303
+ // escaping paths), EXCEPT through a symbolic link — which only the disk
304
+ // reveals.
305
+ const themeAbs = insideKit(dir, manifest.theme);
306
+ const layoutsAbs = insideKit(dir, manifest.layouts);
307
+ if (manifest.theme !== DEFAULT_THEME && !themeAbs)
308
+ diagnostics.push({
309
+ severity: 'error',
310
+ code: 'KIT_INVALID',
311
+ message: `Kit: theme "${manifest.theme}" leaves the kit (symbolic link) — kit skipped.`,
312
+ });
313
+ if (manifest.layouts !== DEFAULT_LAYOUTS && !layoutsAbs)
314
+ diagnostics.push({
315
+ severity: 'error',
316
+ code: 'KIT_INVALID',
317
+ message: `Kit: layouts "${manifest.layouts}" leaves the kit (symbolic link) — kit skipped.`,
318
+ });
319
+ if (diagnostics.some((d) => d.severity === 'error')) return { ...bare, diagnostics };
320
+
321
+ const themeFile = themeAbs && isFile(themeAbs) ? themeAbs : null;
322
+ const layoutsDir = layoutsAbs && isDir(layoutsAbs) ? layoutsAbs : null;
323
+
324
+ // a theme that is DECLARED but absent is an error in the kit; the default
325
+ // being absent is only the absence of design tokens, legitimate for a kit of
326
+ // layouts alone
327
+ if (!themeFile && manifest.theme !== DEFAULT_THEME)
328
+ diagnostics.push({
329
+ severity: 'error',
330
+ code: 'KIT_INVALID',
331
+ message: `Kit "${manifest.name}": theme "${manifest.theme}" declared but not found.`,
332
+ });
333
+ else if (!themeFile && !layoutsDir)
334
+ diagnostics.push({
335
+ severity: 'error',
336
+ code: 'KIT_INVALID',
337
+ message: `Kit "${manifest.name}": neither ${DEFAULT_THEME} nor ${DEFAULT_LAYOUTS}/ — a kit must bring design tokens, layouts, or both.`,
338
+ });
339
+
340
+ if (diagnostics.some((d) => d.severity === 'error')) return { ...bare, diagnostics };
341
+ return { manifest, dir, themeFile, layoutsDir, diagnostics };
342
+ }