@kaydash9999/banker-plugins 0.15.2 → 0.16.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +65 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +238 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +204 -44
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +817 -0
- package/skills/3d-intro-setup/SKILL.md +120 -47
- package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
|
@@ -2,32 +2,45 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* First-party assembler for the 3d-intro-build skill.
|
|
4
4
|
*
|
|
5
|
-
* Takes a project directory that already holds the generated stills +
|
|
6
|
-
*
|
|
5
|
+
* Takes a project directory that already holds the generated stills + clips (+ an intro.json
|
|
6
|
+
* manifest describing them) and produces the intro page:
|
|
7
7
|
* <outDir>/
|
|
8
|
-
* index.html (
|
|
8
|
+
* index.html (head of index-template.html; no inline script or style)
|
|
9
9
|
* scrub-engine.js (copied verbatim from references/)
|
|
10
|
+
* scrub-engine.css (the CSS the engine injects, taken from scrub-engine.js unchanged)
|
|
11
|
+
* scrub-engine.LICENSE.txt (scroll-world's MIT notice, which ships with the engine files)
|
|
12
|
+
* theme.css (the template's --sw-* tokens, set from the manifest theme)
|
|
13
|
+
* intro-fixes.css (layout, button text and overflow fixes for the engine, every page)
|
|
14
|
+
* intro.js (the mountScrollWorld config and call)
|
|
15
|
+
* panel-glass.css (only with "panel": "glass")
|
|
16
|
+
* step-nav.js/.css (only with "stepNav": scene-by-scene navigation)
|
|
10
17
|
* assets/<id>.<ext> (scene posters/stills)
|
|
11
|
-
* assets/vid/<id>.mp4 (per-scene
|
|
12
|
-
* assets/vid/connN.mp4 (optional connectors)
|
|
13
|
-
*
|
|
18
|
+
* assets/vid/<id>.mp4 (per-scene clips: dives, or the short holds of the hold-and-flight mode)
|
|
19
|
+
* assets/vid/connN.mp4 (optional connectors: the flights between scenes)
|
|
20
|
+
* Nothing inline, so the page runs under a strict Content-Security-Policy (no 'unsafe-inline';
|
|
21
|
+
* media-src needs blob:). The engine still tries to inject its <style>: a site with a CSP adds the
|
|
22
|
+
* reported `styleHash` to style-src to keep that refusal out of the console (scrub-engine.css
|
|
23
|
+
* carries the same rules either way). Serve <outDir> with serve.mjs (--csp to check under a policy).
|
|
14
24
|
*
|
|
15
|
-
* Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url). No external
|
|
16
|
-
* no shell
|
|
25
|
+
* Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url / node:crypto). No external
|
|
26
|
+
* deps, no shell; cross-platform (path.join / path.sep; asset URLs are always posix '/').
|
|
17
27
|
*
|
|
18
28
|
* The manifest (intro.json) mirrors the mountScrollWorld config, but its `still` / `clip` /
|
|
19
29
|
* `connectors` values are paths to SOURCE files relative to projectDir; this assembler copies
|
|
20
30
|
* them into assets/ and rewrites the config to the copied relative URLs. Shape:
|
|
21
31
|
*
|
|
22
32
|
* {
|
|
23
|
-
* "pageTitle": "BRAND
|
|
33
|
+
* "pageTitle": "BRAND - the world of SUBJECT", // optional (else derived)
|
|
24
34
|
* "pageDescription": "Scroll to fly through ...", // optional (else derived)
|
|
35
|
+
* "lang": "ko", // optional <html lang> (template: en)
|
|
25
36
|
* "brand": { "name": "BRAND", "href": "#top" }, // optional
|
|
26
37
|
* "cta": { "label": "Order now", "href": "#finale" }, // optional top-bar CTA
|
|
27
38
|
* "hint": "scroll to fly in", // optional
|
|
28
39
|
* "theme": { "bg":"#F5EDE0","ink":"#241d2b","inkSoft":"#6a6072","accent":"#9B7EBD" }, // optional
|
|
29
40
|
* "diveScroll": 1.3, "connScroll": 0.9, "crossfade": 0.12, // optional
|
|
30
41
|
* "nav": true, "atmosphere": true, // optional (only false is emitted)
|
|
42
|
+
* "panel": "glass", // optional frosted-glass copy panel
|
|
43
|
+
* "stepNav": { "end": "#main", ... }, // optional, see step-nav.js for the options
|
|
31
44
|
* "sections": [ // required, >= 1, in order
|
|
32
45
|
* { "id":"sceneA", "label":"Scene A",
|
|
33
46
|
* "still":"still-1.png", "clip":"dive-1.mp4", // required (relative to projectDir)
|
|
@@ -47,6 +60,7 @@
|
|
|
47
60
|
* outDir output site dir (default: <projectDir>/site)
|
|
48
61
|
* --manifest manifest path (default: <projectDir>/intro.json)
|
|
49
62
|
*/
|
|
63
|
+
import crypto from 'node:crypto';
|
|
50
64
|
import fs from 'node:fs';
|
|
51
65
|
import path from 'node:path';
|
|
52
66
|
import { fileURLToPath } from 'node:url';
|
|
@@ -73,6 +87,53 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
|
|
|
73
87
|
|
|
74
88
|
// ---- config build -----------------------------------------------------------
|
|
75
89
|
|
|
90
|
+
// The copy and pacing fields a section carries over to the engine as they are.
|
|
91
|
+
const SECTION_FIELDS = ['accent', 'eyebrow', 'title', 'body', 'scroll', 'linger', 'cta'];
|
|
92
|
+
// Where each media field of a section is copied (the source extension is kept).
|
|
93
|
+
const MEDIA = [
|
|
94
|
+
['still', (id, src) => `assets/${id}${extOf(src)}`],
|
|
95
|
+
['stillMobile', (id, src) => `assets/${id}-m${extOf(src)}`],
|
|
96
|
+
['clip', (id, src) => `assets/vid/${id}${extOf(src) || '.mp4'}`],
|
|
97
|
+
['clipMobile', (id, src) => `assets/vid/${id}-m${extOf(src) || '.mp4'}`],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
function requireFields(s, i) {
|
|
101
|
+
if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
|
|
102
|
+
for (const k of ['still', 'clip']) if (!s[k]) throw new Error(`assemble: section[${i}] (${s.id}) missing "${k}"`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// One section of the engine config, its still and clips copied into assets/.
|
|
106
|
+
function sectionOut(s, i, copy) {
|
|
107
|
+
requireFields(s, i);
|
|
108
|
+
const out = { id: s.id, label: s.label || s.id };
|
|
109
|
+
for (const [k, dest] of MEDIA) if (s[k]) out[k] = copy(s[k], dest(s.id, s[k]));
|
|
110
|
+
for (const k of SECTION_FIELDS) if (s[k] != null && s[k] !== '') out[k] = s[k];
|
|
111
|
+
if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Connectors: one per gap between sections, in order; a null entry means "crossfade directly".
|
|
116
|
+
function connectorsOut(list, n, suffix, copy) {
|
|
117
|
+
const src = Array.isArray(list) ? list : [];
|
|
118
|
+
return Array.from({ length: Math.max(0, n - 1) }, (_, i) => (src[i]
|
|
119
|
+
? copy(src[i], `assets/vid/conn${i + 1}${suffix}${extOf(src[i]) || '.mp4'}`) : null));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The config in the shape mountScrollWorld reads (see scrub-engine.js).
|
|
123
|
+
function engineConfig(m, sections, connectors, connectorsMobile) {
|
|
124
|
+
const config = {};
|
|
125
|
+
for (const k of ['brand', 'cta']) if (m[k]) config[k] = m[k];
|
|
126
|
+
config.hint = m.hint || 'scroll to fly in';
|
|
127
|
+
config.diveScroll = m.diveScroll ?? 1.3;
|
|
128
|
+
config.connScroll = m.connScroll ?? 0.9;
|
|
129
|
+
if (m.crossfade != null) config.crossfade = m.crossfade;
|
|
130
|
+
for (const k of ['nav', 'atmosphere']) if (m[k] === false) config[k] = false;
|
|
131
|
+
config.sections = sections;
|
|
132
|
+
config.connectors = connectors;
|
|
133
|
+
if (connectorsMobile.some(Boolean)) config.connectorsMobile = connectorsMobile;
|
|
134
|
+
return config;
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
/**
|
|
77
138
|
* Build the exact mountScrollWorld config object from a manifest, copying every referenced
|
|
78
139
|
* asset into outDir/assets. Returns { config, copied:[destPosix,...] }.
|
|
@@ -80,120 +141,118 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
|
|
|
80
141
|
function buildConfig(manifest, projectDir, outDir) {
|
|
81
142
|
const sections = Array.isArray(manifest.sections) ? manifest.sections : [];
|
|
82
143
|
if (sections.length === 0) throw new Error('assemble: manifest.sections must have at least one section');
|
|
83
|
-
|
|
84
144
|
const copied = [];
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!s.clip) throw new Error(`assemble: section[${i}] (${s.id}) missing "clip"`);
|
|
91
|
-
|
|
92
|
-
const out = { id: s.id, label: s.label || s.id };
|
|
93
|
-
|
|
94
|
-
// poster still (keep source extension so png/webp/jpg all work)
|
|
95
|
-
out.still = track(copyAsset(projectDir, outDir, s.still, `assets/${s.id}${extOf(s.still)}`));
|
|
96
|
-
if (s.stillMobile) {
|
|
97
|
-
out.stillMobile = track(copyAsset(projectDir, outDir, s.stillMobile, `assets/${s.id}-m${extOf(s.stillMobile)}`));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// dive clip
|
|
101
|
-
out.clip = track(copyAsset(projectDir, outDir, s.clip, `assets/vid/${s.id}${extOf(s.clip) || '.mp4'}`));
|
|
102
|
-
if (s.clipMobile) {
|
|
103
|
-
out.clipMobile = track(copyAsset(projectDir, outDir, s.clipMobile, `assets/vid/${s.id}-m${extOf(s.clipMobile) || '.mp4'}`));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (s.accent) out.accent = s.accent;
|
|
107
|
-
if (s.eyebrow) out.eyebrow = s.eyebrow;
|
|
108
|
-
if (s.title) out.title = s.title;
|
|
109
|
-
if (s.body) out.body = s.body;
|
|
110
|
-
if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
|
|
111
|
-
if (s.scroll != null) out.scroll = s.scroll;
|
|
112
|
-
if (s.linger != null) out.linger = s.linger;
|
|
113
|
-
if (s.cta) out.cta = s.cta; // last section only (engine renders it wherever present)
|
|
114
|
-
return out;
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// connectors: length === sections.length - 1; null entries mean "crossfade directly".
|
|
118
|
-
const srcConn = Array.isArray(manifest.connectors) ? manifest.connectors : [];
|
|
119
|
-
const srcConnM = Array.isArray(manifest.connectorsMobile) ? manifest.connectorsMobile : [];
|
|
120
|
-
const connectorsOut = [];
|
|
121
|
-
const connectorsMobileOut = [];
|
|
122
|
-
for (let i = 0; i < sectionsOut.length - 1; i++) {
|
|
123
|
-
const c = srcConn[i];
|
|
124
|
-
connectorsOut.push(c ? track(copyAsset(projectDir, outDir, c, `assets/vid/conn${i + 1}${extOf(c) || '.mp4'}`)) : null);
|
|
125
|
-
const cm = srcConnM[i];
|
|
126
|
-
connectorsMobileOut.push(cm ? track(copyAsset(projectDir, outDir, cm, `assets/vid/conn${i + 1}-m${extOf(cm) || '.mp4'}`)) : null);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Assemble in the shape mountScrollWorld reads (see scrub-engine.js).
|
|
130
|
-
const config = {};
|
|
131
|
-
if (manifest.brand) config.brand = manifest.brand;
|
|
132
|
-
if (manifest.cta) config.cta = manifest.cta;
|
|
133
|
-
config.hint = manifest.hint || 'scroll to fly in';
|
|
134
|
-
config.diveScroll = manifest.diveScroll != null ? manifest.diveScroll : 1.3;
|
|
135
|
-
config.connScroll = manifest.connScroll != null ? manifest.connScroll : 0.9;
|
|
136
|
-
if (manifest.crossfade != null) config.crossfade = manifest.crossfade;
|
|
137
|
-
if (manifest.nav === false) config.nav = false;
|
|
138
|
-
if (manifest.atmosphere === false) config.atmosphere = false;
|
|
139
|
-
config.sections = sectionsOut;
|
|
140
|
-
config.connectors = connectorsOut;
|
|
141
|
-
if (connectorsMobileOut.some(Boolean)) config.connectorsMobile = connectorsMobileOut;
|
|
142
|
-
|
|
143
|
-
return { config, copied };
|
|
145
|
+
const copy = (src, dest) => { copied.push(copyAsset(projectDir, outDir, src, dest)); return dest; };
|
|
146
|
+
const sectionsOut = sections.map((s, i) => sectionOut(s, i, copy));
|
|
147
|
+
const connectors = connectorsOut(manifest.connectors, sectionsOut.length, '', copy);
|
|
148
|
+
const connectorsMobile = connectorsOut(manifest.connectorsMobile, sectionsOut.length, '-m', copy);
|
|
149
|
+
return { config: engineConfig(manifest, sectionsOut, connectors, connectorsMobile), copied };
|
|
144
150
|
}
|
|
145
151
|
|
|
146
|
-
// ----
|
|
152
|
+
// ---- page generation -------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
const PANELS = { glass: 'panel-glass.css' };
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The CSS the engine injects into <head>, exactly: the `css` template literal wrapped in @layer sw.
|
|
158
|
+
* A template literal turns CRLF and CR into LF, so a checkout with CRLF line ends (git on Windows)
|
|
159
|
+
* is read the same way and gives the same hash.
|
|
160
|
+
*/
|
|
161
|
+
export function engineCss(rawSource) {
|
|
162
|
+
const engineSource = String(rawSource).replace(/\r\n?/g, '\n');
|
|
163
|
+
const open = engineSource.indexOf('const css = `');
|
|
164
|
+
const close = open === -1 ? -1 : engineSource.indexOf('`;', open + 13);
|
|
165
|
+
if (close === -1) throw new Error('assemble: scrub-engine.js no longer holds `const css = `...``');
|
|
166
|
+
const css = engineSource.slice(open + 13, close);
|
|
167
|
+
if (/\\|\$\{/.test(css)) throw new Error('assemble: the engine CSS has escapes or substitutions; copy it by hand');
|
|
168
|
+
return '@layer sw {\n' + css + '\n}';
|
|
169
|
+
}
|
|
147
170
|
|
|
148
|
-
// Replace the value of a --sw-* custom property
|
|
149
|
-
function setToken(
|
|
150
|
-
if (!value) return
|
|
171
|
+
// Replace the value of a --sw-* custom property, keeping comments.
|
|
172
|
+
function setToken(css, name, value) {
|
|
173
|
+
if (!value) return css;
|
|
151
174
|
const re = new RegExp(`(--${name}\\s*:\\s*)[^;]+;`);
|
|
152
|
-
return
|
|
175
|
+
return css.replace(re, `$1${value};`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The template's inline theme block, with the manifest's colours, as a stylesheet.
|
|
179
|
+
function themeCss(styleInner, theme = {}) {
|
|
180
|
+
let css = setToken(styleInner, 'sw-bg', theme.bg);
|
|
181
|
+
css = setToken(css, 'sw-ink', theme.ink);
|
|
182
|
+
css = setToken(css, 'sw-ink-soft', theme.inkSoft);
|
|
183
|
+
css = setToken(css, 'sw-accent', theme.accent);
|
|
184
|
+
return css.replace(/^\n+/, '').replace(/\s*$/, '\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stylesheets(manifest) {
|
|
188
|
+
return ['scrub-engine.css', 'theme.css', 'intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
|
|
189
|
+
manifest.stepNav ? 'step-nav.css' : null].filter(Boolean);
|
|
153
190
|
}
|
|
154
191
|
|
|
155
|
-
/**
|
|
156
|
-
function renderIndexHtml(template,
|
|
192
|
+
/** index.html from the template's head: title, description and lang set, its inline style replaced by links. */
|
|
193
|
+
function renderIndexHtml(template, manifest) {
|
|
157
194
|
const brandName = manifest.brand?.name || 'BRAND';
|
|
158
195
|
const subject = manifest.subject || 'SUBJECT';
|
|
159
|
-
const title = manifest.pageTitle || `${brandName}
|
|
196
|
+
const title = manifest.pageTitle || `${brandName} - the world of ${subject}`;
|
|
160
197
|
const desc = manifest.pageDescription || `Scroll to fly through the world of ${brandName.replace(/\.+$/, '')}.`;
|
|
161
|
-
|
|
162
|
-
// Everything up to the engine <script> tag is the head+body-open; regenerate the tail.
|
|
163
198
|
const marker = '<script src="scrub-engine.js">';
|
|
164
199
|
const idx = template.indexOf(marker);
|
|
165
200
|
if (idx === -1) throw new Error('assemble: index-template.html missing the scrub-engine.js script tag');
|
|
166
201
|
let head = template.slice(0, idx).replace(/\s*$/, '\n');
|
|
167
|
-
|
|
168
202
|
head = head.replace(/<title>[^<]*<\/title>/, `<title>${escHtml(title)}</title>`);
|
|
169
203
|
head = head.replace(/(<meta name="description" content=")[^"]*(")/, `$1${escAttr(desc)}$2`);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
head =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
204
|
+
if (manifest.lang) head = head.replace(/<html lang="[^"]*">/, `<html lang="${escAttr(manifest.lang)}">`);
|
|
205
|
+
const links = stylesheets(manifest).map((f) => ` <link rel="stylesheet" href="${f}" />`).join('\n');
|
|
206
|
+
head = head.replace(/[ \t]*<style>[\s\S]*?<\/style>\n?/, `${links}\n`);
|
|
207
|
+
const scripts = ['scrub-engine.js', manifest.stepNav ? 'step-nav.js' : null, 'intro.js'].filter(Boolean)
|
|
208
|
+
.map((f) => ` <script src="${f}"></script>`).join('\n');
|
|
209
|
+
return `${head}${scripts}\n</body>\n</html>\n`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The config and the mount, out of the page so no inline script is needed.
|
|
213
|
+
function introJs(config, nav) {
|
|
214
|
+
const json = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n ');
|
|
215
|
+
const lines = nav
|
|
216
|
+
? [`var nav = ${json(nav === true ? {} : nav)};`, 'if (window.IntroStepNav) window.IntroStepNav.prefetch(config, nav);',
|
|
217
|
+
'mountScrollWorld(world, config);', 'if (window.IntroStepNav) window.IntroStepNav.attach(world, config, nav);']
|
|
218
|
+
: ['mountScrollWorld(world, config);'];
|
|
219
|
+
return `// Generated by assemble.mjs from intro.json. Kept out of index.html so the page runs under a
|
|
220
|
+
// Content-Security-Policy without 'unsafe-inline'.
|
|
221
|
+
(function () {
|
|
222
|
+
var world = document.getElementById('world');
|
|
223
|
+
var config = ${json(config)};
|
|
224
|
+
${lines.map((l) => ` ${l}`).join('\n')}
|
|
225
|
+
})();
|
|
184
226
|
`;
|
|
185
|
-
return head + tail;
|
|
186
227
|
}
|
|
187
228
|
|
|
188
229
|
const escHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
189
230
|
const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
190
231
|
|
|
232
|
+
// Heads the copied engine rules: they are scroll-world's (MIT), whose notice ships beside them.
|
|
233
|
+
const ENGINE_NOTICE = '/* scroll-world (https://github.com/oso95/scroll-world), MIT License, (c) 2026 cyw.\n'
|
|
234
|
+
+ ' The rules scrub-engine.js injects, copied unchanged. Notice: scrub-engine.LICENSE.txt */\n';
|
|
235
|
+
|
|
236
|
+
function writeStatic(out, manifest, template) {
|
|
237
|
+
const engineSource = fs.readFileSync(path.join(HERE, 'scrub-engine.js'), 'utf8');
|
|
238
|
+
fs.copyFileSync(path.join(HERE, 'scrub-engine.js'), path.join(out, 'scrub-engine.js'));
|
|
239
|
+
fs.copyFileSync(path.join(HERE, 'LICENSE'), path.join(out, 'scrub-engine.LICENSE.txt'));
|
|
240
|
+
const css = engineCss(engineSource);
|
|
241
|
+
fs.writeFileSync(path.join(out, 'scrub-engine.css'), ENGINE_NOTICE + css);
|
|
242
|
+
const style = /<style>([\s\S]*?)<\/style>/.exec(template);
|
|
243
|
+
fs.writeFileSync(path.join(out, 'theme.css'), themeCss(style ? style[1] : '', manifest.theme));
|
|
244
|
+
const copies = ['intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
|
|
245
|
+
manifest.stepNav ? 'step-nav.css' : null, manifest.stepNav ? 'step-nav.js' : null].filter(Boolean);
|
|
246
|
+
for (const f of copies) fs.copyFileSync(path.join(HERE, f), path.join(out, f));
|
|
247
|
+
return `'sha256-${crypto.createHash('sha256').update(css).digest('base64')}'`;
|
|
248
|
+
}
|
|
249
|
+
|
|
191
250
|
// ---- public API -------------------------------------------------------------
|
|
192
251
|
|
|
193
252
|
/**
|
|
194
|
-
* Assemble
|
|
195
|
-
* writes index.html
|
|
196
|
-
* @returns {{ outDir:string, index:string, engine:string, assets:string[] }}
|
|
253
|
+
* Assemble the intro page. Copies the engine, its fixes and all referenced assets into outDir and
|
|
254
|
+
* writes index.html, intro.js and the stylesheets (see the header).
|
|
255
|
+
* @returns {{ outDir:string, index:string, engine:string, assets:string[], styleHash:string }}
|
|
197
256
|
*/
|
|
198
257
|
export function assemble({ projectDir, outDir, manifestPath } = {}) {
|
|
199
258
|
if (!projectDir) throw new Error('assemble: projectDir is required');
|
|
@@ -205,21 +264,17 @@ export function assemble({ projectDir, outDir, manifestPath } = {}) {
|
|
|
205
264
|
'Write an intro.json in the project dir (see the header of assemble.mjs for its shape).');
|
|
206
265
|
}
|
|
207
266
|
const manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
267
|
+
if (manifest.panel && !PANELS[manifest.panel]) throw new Error(`assemble: unknown panel "${manifest.panel}" (known: ${Object.keys(PANELS).join(', ')})`);
|
|
208
268
|
|
|
209
269
|
fs.mkdirSync(out, { recursive: true });
|
|
210
270
|
const { config, copied } = buildConfig(manifest, proj, out);
|
|
211
|
-
|
|
212
|
-
// Vendored engine sits next to this script; copy it beside index.html.
|
|
213
|
-
const engineSrc = path.join(HERE, 'scrub-engine.js');
|
|
214
|
-
if (!fs.existsSync(engineSrc)) throw new Error(`assemble: scrub-engine.js not found at ${engineSrc}`);
|
|
215
|
-
fs.copyFileSync(engineSrc, path.join(out, 'scrub-engine.js'));
|
|
216
|
-
|
|
217
271
|
const template = fs.readFileSync(path.join(HERE, 'index-template.html'), 'utf8');
|
|
218
|
-
const
|
|
272
|
+
const styleHash = writeStatic(out, manifest, template);
|
|
273
|
+
fs.writeFileSync(path.join(out, 'intro.js'), introJs(config, manifest.stepNav));
|
|
219
274
|
const indexPath = path.join(out, 'index.html');
|
|
220
|
-
fs.writeFileSync(indexPath,
|
|
275
|
+
fs.writeFileSync(indexPath, renderIndexHtml(template, manifest));
|
|
221
276
|
|
|
222
|
-
return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied };
|
|
277
|
+
return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied, styleHash };
|
|
223
278
|
}
|
|
224
279
|
|
|
225
280
|
// ---- CLI --------------------------------------------------------------------
|
|
@@ -244,7 +299,8 @@ if (invokedDirectly) {
|
|
|
244
299
|
console.log(`assembled ${r.assets.length} asset(s) -> ${r.outDir}`);
|
|
245
300
|
console.log(`index: ${r.index}`);
|
|
246
301
|
console.log(`engine: ${r.engine}`);
|
|
247
|
-
console.log(
|
|
302
|
+
console.log(`CSP: the engine injects one <style>; to allow it add ${r.styleHash} to style-src (scrub-engine.css carries the same rules)`);
|
|
303
|
+
console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir} [--csp strict]`);
|
|
248
304
|
} catch (e) {
|
|
249
305
|
console.error(String(e.message || e));
|
|
250
306
|
process.exit(1);
|