@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.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -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
|
+
}
|
package/src/deck/kit.mjs
ADDED
|
@@ -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
|
+
}
|