@supportpages.io/wtfm 0.0.0-stage → 0.3.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 +201 -0
- package/NOTICE +23 -0
- package/README.md +392 -2
- package/dist/actions.d.ts +98 -0
- package/dist/actions.js +86 -0
- package/dist/actions.js.map +1 -0
- package/dist/agent-settings.d.ts +22 -0
- package/dist/agent-settings.js +35 -0
- package/dist/agent-settings.js.map +1 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +159 -0
- package/dist/api.js.map +1 -0
- package/dist/article-link.d.ts +16 -0
- package/dist/article-link.js +25 -0
- package/dist/article-link.js.map +1 -0
- package/dist/artifacts.d.ts +45 -0
- package/dist/artifacts.js +144 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/brand.d.ts +4 -0
- package/dist/brand.js +9 -0
- package/dist/brand.js.map +1 -0
- package/dist/bridge.d.ts +2964 -0
- package/dist/bridge.js +1036 -0
- package/dist/bridge.js.map +1 -0
- package/dist/capacity.d.ts +5 -0
- package/dist/capacity.js +11 -0
- package/dist/capacity.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +88 -0
- package/dist/credentials.js.map +1 -0
- package/dist/development-tls.d.ts +8 -0
- package/dist/development-tls.js +38 -0
- package/dist/development-tls.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +11 -0
- package/dist/export.js +91 -0
- package/dist/export.js.map +1 -0
- package/dist/hosted-operations.d.ts +108 -0
- package/dist/hosted-operations.js +120 -0
- package/dist/hosted-operations.js.map +1 -0
- package/dist/hosting-benefits.d.ts +66 -0
- package/dist/hosting-benefits.js +67 -0
- package/dist/hosting-benefits.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/local-inventory.d.ts +22 -0
- package/dist/local-inventory.js +59 -0
- package/dist/local-inventory.js.map +1 -0
- package/dist/local-setup.d.ts +208 -0
- package/dist/local-setup.js +140 -0
- package/dist/local-setup.js.map +1 -0
- package/dist/pairing.d.ts +71 -0
- package/dist/pairing.js +235 -0
- package/dist/pairing.js.map +1 -0
- package/dist/preferences.d.ts +18 -0
- package/dist/preferences.js +44 -0
- package/dist/preferences.js.map +1 -0
- package/dist/progress.d.ts +205 -0
- package/dist/progress.js +224 -0
- package/dist/progress.js.map +1 -0
- package/dist/reminders.d.ts +31 -0
- package/dist/reminders.js +73 -0
- package/dist/reminders.js.map +1 -0
- package/dist/replace-connection.d.ts +6 -0
- package/dist/replace-connection.js +77 -0
- package/dist/replace-connection.js.map +1 -0
- package/dist/repository-actions.d.ts +18 -0
- package/dist/repository-actions.js +9 -0
- package/dist/repository-actions.js.map +1 -0
- package/dist/repository-benefits.d.ts +96 -0
- package/dist/repository-benefits.js +64 -0
- package/dist/repository-benefits.js.map +1 -0
- package/dist/run-update.d.ts +17 -0
- package/dist/run-update.js +27 -0
- package/dist/run-update.js.map +1 -0
- package/dist/runs.d.ts +541 -0
- package/dist/runs.js +146 -0
- package/dist/runs.js.map +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +58 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +277 -0
- package/dist/schema.js +66 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +1714 -0
- package/dist/session.js +619 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +9 -0
- package/dist/settings.js +21 -0
- package/dist/settings.js.map +1 -0
- package/dist/sync.d.ts +495 -0
- package/dist/sync.js +191 -0
- package/dist/sync.js.map +1 -0
- package/dist/telemetry-scrub.d.ts +13 -0
- package/dist/telemetry-scrub.js +51 -0
- package/dist/telemetry-scrub.js.map +1 -0
- package/dist/telemetry.d.ts +73 -0
- package/dist/telemetry.js +173 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/walkthroughs.d.ts +224 -0
- package/dist/walkthroughs.js +109 -0
- package/dist/walkthroughs.js.map +1 -0
- package/dist/workspace.d.ts +14 -0
- package/dist/workspace.js +127 -0
- package/dist/workspace.js.map +1 -0
- package/dist/writer-agent.d.ts +21 -0
- package/dist/writer-agent.js +27 -0
- package/dist/writer-agent.js.map +1 -0
- package/dist/writer-entry.d.ts +171 -0
- package/dist/writer-entry.js +233 -0
- package/dist/writer-entry.js.map +1 -0
- package/dist/writing-style.d.ts +7 -0
- package/dist/writing-style.js +52 -0
- package/dist/writing-style.js.map +1 -0
- package/engine/SYNC.json +4 -0
- package/engine/VERSION +1 -0
- package/engine/detect-project/README.md +141 -0
- package/engine/detect-project/SKILL.md +1421 -0
- package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
- package/engine/detect-project/assets/game/game-frame.css +132 -0
- package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
- package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/macosui/fonts.css +15 -0
- package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
- package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
- package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
- package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
- package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
- package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
- package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
- package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
- package/engine/detect-project/assets/mobileui/fonts.css +16 -0
- package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
- package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
- package/engine/detect-project/assets/mobileui/icons.css +31 -0
- package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
- package/engine/detect-project/assets/mobileui/platforms.json +46 -0
- package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
- package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
- package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
- package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
- package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
- package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
- package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
- package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
- package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
- package/engine/detect-project/assets/webtui/themes.json +37 -0
- package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
- package/engine/detect-project/assets/win32ui/7css.css +2 -0
- package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
- package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
- package/engine/detect-project/package-lock.json +12 -0
- package/engine/detect-project/package.json +10 -0
- package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
- package/engine/detect-project/scripts/check_css_health.js +412 -0
- package/engine/detect-project/scripts/check_project_map.js +150 -0
- package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
- package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
- package/engine/detect-project/scripts/classify_app_type.sh +246 -0
- package/engine/detect-project/scripts/classify_surface.sh +95 -0
- package/engine/detect-project/scripts/classify_workspace.js +39 -0
- package/engine/detect-project/scripts/compile_css.sh +447 -0
- package/engine/detect-project/scripts/detect_static.js +645 -0
- package/engine/detect-project/scripts/detect_structure.js +187 -0
- package/engine/detect-project/scripts/include_census.js +451 -0
- package/engine/detect-project/scripts/json_get.js +142 -0
- package/engine/detect-project/scripts/merge_json.js +52 -0
- package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
- package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
- package/engine/detect-project/scripts/run_css_build.sh +40 -0
- package/engine/detect-project/scripts/sanitize_css.js +83 -0
- package/engine/detect-project/scripts/test_classify_surface.js +108 -0
- package/engine/detect-project/scripts/test_node_helpers.js +142 -0
- package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
- package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
- package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
- package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
- package/engine/detect-project/scripts/theme_overrides.js +169 -0
- package/engine/detect-project/scripts/write_branding.js +129 -0
- package/engine/generate-illustrated-article/SKILL.md +461 -0
- package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
- package/engine/generate-illustrated-article/contracts/game.md +38 -0
- package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
- package/engine/generate-illustrated-article/contracts/macos.md +79 -0
- package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
- package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
- package/engine/generate-illustrated-article/contracts/win32.md +43 -0
- package/engine/generate-illustrated-article/package-lock.json +366 -0
- package/engine/generate-illustrated-article/package.json +15 -0
- package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
- package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
- package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
- package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
- package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
- package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
- package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
- package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
- package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
- package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
- package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
- package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
- package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
- package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
- package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
- package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
- package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
- package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
- package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
- package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
- package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
- package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
- package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
- package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
- package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
- package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
- package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
- package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
- package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
- package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
- package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
- package/install.sh +14 -0
- package/package.json +57 -4
- package/scripts/auth.mjs +33 -0
- package/scripts/auto-update.mjs +7 -0
- package/scripts/build-plugin.mjs +58 -0
- package/scripts/build-release.mjs +94 -0
- package/scripts/check-release.mjs +40 -0
- package/scripts/check-runtime.mjs +10 -0
- package/scripts/cli.mjs +8 -0
- package/scripts/install.mjs +64 -0
- package/scripts/lib/agent-runner.mjs +181 -0
- package/scripts/lib/agent-settings.mjs +215 -0
- package/scripts/lib/article-skills.mjs +82 -0
- package/scripts/lib/auto-update.mjs +50 -0
- package/scripts/lib/brand.mjs +9 -0
- package/scripts/lib/browser.mjs +12 -0
- package/scripts/lib/claude-connection.mjs +33 -0
- package/scripts/lib/claude-permissions.mjs +47 -0
- package/scripts/lib/claude-plugin.mjs +15 -0
- package/scripts/lib/claude-writer.mjs +10 -0
- package/scripts/lib/cli-main.mjs +85 -0
- package/scripts/lib/cli.mjs +888 -0
- package/scripts/lib/codex-config.mjs +51 -0
- package/scripts/lib/codex-integration.mjs +61 -0
- package/scripts/lib/codex-skill.mjs +34 -0
- package/scripts/lib/harness-models.mjs +109 -0
- package/scripts/lib/install.mjs +269 -0
- package/scripts/lib/managed-writer.mjs +38 -0
- package/scripts/lib/planning.mjs +263 -0
- package/scripts/lib/prepare-update.mjs +85 -0
- package/scripts/lib/refresh-writers.mjs +12 -0
- package/scripts/lib/remove.mjs +167 -0
- package/scripts/lib/renderer.mjs +34 -0
- package/scripts/lib/terminal.mjs +252 -0
- package/scripts/lib/uninit.mjs +72 -0
- package/scripts/lib/update.mjs +55 -0
- package/scripts/lib/writer-recovery.mjs +35 -0
- package/scripts/lib/yolo.mjs +362 -0
- package/scripts/plugin-session.mjs +65 -0
- package/scripts/prepare-update.mjs +12 -0
- package/scripts/publish-release.mjs +92 -0
- package/scripts/skills/supportpages/SKILL.md +120 -0
- package/scripts/smoke-release.mjs +101 -0
- package/server.json +27 -0
|
@@ -0,0 +1,1691 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* inject_assets.js — Post-process generated mockup HTML files.
|
|
6
|
+
*
|
|
7
|
+
* Shared by generate-illustrated-article (no --walkthrough) and generate-walkthrough
|
|
8
|
+
* (--walkthrough). generate-walkthrough/scripts/inject_assets.js is a symlink to this
|
|
9
|
+
* file; the two skills differ only by the --walkthrough flag (see below). Keep both
|
|
10
|
+
* modes working when editing.
|
|
11
|
+
*
|
|
12
|
+
* For each step_*.html / block_*.html in <html_dir>:
|
|
13
|
+
* - Replace <!-- INJECT_CSS --> with framework CDN / compiled CSS / Tailwind
|
|
14
|
+
* fallback (see "CSS source resolution" below). Injection is idempotent — a
|
|
15
|
+
* RTFM_CSS_INJECTED sentinel lets STEP-4 lint-retry re-runs skip re-injecting.
|
|
16
|
+
* - Render-time JIT: when project_map.json has a `css_build` recipe, compile the
|
|
17
|
+
* mockups' own classes into <html_dir>/mockup.css (jit_mockup_css.js) and link
|
|
18
|
+
* it in place of the flaky Play CDN. Skips cleanly with no recipe. Runs in BOTH
|
|
19
|
+
* modes, so walkthroughs get the same JIT fidelity as articles.
|
|
20
|
+
* - Replace {{img:filename}} tokens with project image data URIs from <images_json>.
|
|
21
|
+
* - Replace {{generated:id}} tokens with generated-image files declared by the
|
|
22
|
+
* optional --generated-images manifest.
|
|
23
|
+
*
|
|
24
|
+
* With --walkthrough <actions_json>, also inject:
|
|
25
|
+
* - A zoomable stage wrapper (<div data-walkthrough-stage>) around the mockup.
|
|
26
|
+
* - Polish CSS: target glow, click ripple, page-transition fade overlay.
|
|
27
|
+
* - Auto-generates step_intro.html / step_outro.html title cards from
|
|
28
|
+
* article.json (skipping the stage wrapper on those pages).
|
|
29
|
+
*
|
|
30
|
+
* The step text is carried by the narration + subtitles/transcript, so no
|
|
31
|
+
* on-screen caption chrome is injected.
|
|
32
|
+
*
|
|
33
|
+
* With --branding <branding_json>, build a richer head block layered as:
|
|
34
|
+
* 1. App's compiled CSS (if branding.compiled_css_path is set) — highest fidelity
|
|
35
|
+
* 2. Framework CDN (Bootstrap / Bulma / Foundation / Tailwind) if no compiled CSS
|
|
36
|
+
* 3. Tailwind runtime config override (if framework is tailwind + extend present)
|
|
37
|
+
* 4. Bare Tailwind CDN fallback for tailwind/none/custom frameworks
|
|
38
|
+
* 5. Google Fonts <link>s from branding.google_fonts
|
|
39
|
+
* 6. External stylesheets from branding.external_stylesheets
|
|
40
|
+
* 7. <style data-walkthrough-branding> with :root vars + default colours + CSS-var overrides
|
|
41
|
+
*
|
|
42
|
+
* CSS source resolution (without --branding):
|
|
43
|
+
* - If <css_file_or_empty> is a non-empty file → wrap in <style>
|
|
44
|
+
* - Otherwise → Tailwind CDN
|
|
45
|
+
*
|
|
46
|
+
* Usage:
|
|
47
|
+
* node inject_assets.js <html_dir> <css_file_or_empty> <images_json> \
|
|
48
|
+
* [--walkthrough <actions_json>] [--branding <branding_json>]
|
|
49
|
+
* [--generated-images <generated_images_json>] [--refresh-jit]
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const fs = require('fs');
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const crypto = require('crypto');
|
|
55
|
+
const { spawnSync } = require('child_process');
|
|
56
|
+
|
|
57
|
+
const TAILWIND_CDN = '<script src="https://cdn.tailwindcss.com"></script>';
|
|
58
|
+
// Sentinel marking a mockup whose <!-- INJECT_CSS --> block has already been
|
|
59
|
+
// replaced. STEP 4's lint-retry re-runs processDir on already-injected HTML;
|
|
60
|
+
// the marker lets us skip re-injecting so the block never stacks 2nd/3rd time.
|
|
61
|
+
const INJECTED_SENTINEL = '<!-- RTFM_CSS_INJECTED -->';
|
|
62
|
+
|
|
63
|
+
// All walkthrough-specific styling lives in one block so it can be injected
|
|
64
|
+
// once per page. Includes: page-transition overlay (bidirectional —
|
|
65
|
+
// class-driven), pre-click target glow, click ripple effect, title-card
|
|
66
|
+
// layout. Branding CSS vars (--brand-primary etc.) are produced earlier by
|
|
67
|
+
// buildCssHeadBlock and referenced here with safe fallbacks.
|
|
68
|
+
const WALKTHROUGH_STYLE = [
|
|
69
|
+
'<style data-walkthrough-polish="1">',
|
|
70
|
+
// ── Page-transition overlay ───────────────────────────────────────
|
|
71
|
+
// body::before sits below caption (9999) and cursor (10000) but above
|
|
72
|
+
// mockup content, giving us a fade-in on page load and a fade-out
|
|
73
|
+
// before navigation. Class-driven so the recorder can run it in
|
|
74
|
+
// either direction. The fallback colour is white when no brand
|
|
75
|
+
// colour is set; with a brand colour it becomes a soft brand wash.
|
|
76
|
+
'body::before{',
|
|
77
|
+
"content:'';",
|
|
78
|
+
'position:fixed;top:0;left:0;right:0;bottom:0;',
|
|
79
|
+
'background:color-mix(in srgb,var(--brand-primary,#ffffff) 22%,#ffffff 78%);',
|
|
80
|
+
'z-index:9997;pointer-events:none;',
|
|
81
|
+
'opacity:1;',
|
|
82
|
+
'transition:opacity 520ms ease-out;',
|
|
83
|
+
'}',
|
|
84
|
+
'html.walkthrough-loaded body::before{opacity:0;}',
|
|
85
|
+
'html[data-walkthrough-transition="fading-out"] body::before{',
|
|
86
|
+
'opacity:1;transition:opacity 220ms ease-in;',
|
|
87
|
+
'}',
|
|
88
|
+
// The target highlight ring, click ripple and cursor are drawn by the
|
|
89
|
+
// recorder as overlays (generate-walkthrough/scripts/interaction_fx.js),
|
|
90
|
+
// never as styles on the mockup's own elements.
|
|
91
|
+
// ── Title-card layout ─────────────────────────────────────────────
|
|
92
|
+
// Cards come in a light and a dark theme, picked per project by the
|
|
93
|
+
// logo's ink luminance (dark logo -> light card and vice versa, see
|
|
94
|
+
// writeTitleCards). When a card_bg image is present it covers the
|
|
95
|
+
// flat theme background, so the theme instead follows the image's
|
|
96
|
+
// measured luminance, and a logo whose ink would blend into the image
|
|
97
|
+
// gets a contrast plate (.walkthrough-card-logo--plate-*). Both themes
|
|
98
|
+
// layer soft brand-tinted radial glows and a faint dot grid over a
|
|
99
|
+
// flat base so the card has depth without AI background art.
|
|
100
|
+
// --walkthrough-card-font carries the project's first Google Font when
|
|
101
|
+
// branding provides one.
|
|
102
|
+
// position:relative + overflow:hidden lets the (optional) bg div fill
|
|
103
|
+
// the viewport while still allowing transform:scale to grow past the
|
|
104
|
+
// natural edges without leaking outside the frame.
|
|
105
|
+
'body[data-walkthrough-card]{',
|
|
106
|
+
'margin:0;width:100vw;height:100vh;min-height:100vh;',
|
|
107
|
+
'display:flex;align-items:center;justify-content:center;',
|
|
108
|
+
'background-image:',
|
|
109
|
+
'radial-gradient(color-mix(in srgb,var(--brand-primary,#64748b) 14%,transparent) 1px,transparent 1.5px),',
|
|
110
|
+
'radial-gradient(56rem 38rem at 14% 18%,color-mix(in srgb,var(--brand-primary,#6366f1) 16%,transparent),transparent 70%),',
|
|
111
|
+
'radial-gradient(64rem 44rem at 86% 84%,color-mix(in srgb,var(--brand-accent,var(--brand-primary,#6366f1)) 12%,transparent),transparent 70%);',
|
|
112
|
+
'background-size:26px 26px,auto,auto;',
|
|
113
|
+
'background-color:#fff;',
|
|
114
|
+
"font-family:var(--walkthrough-card-font,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif);",
|
|
115
|
+
'color:#0f172a;',
|
|
116
|
+
'position:relative;overflow:hidden;',
|
|
117
|
+
'}',
|
|
118
|
+
'body[data-walkthrough-card][data-card-theme="dark"]{',
|
|
119
|
+
'background-image:',
|
|
120
|
+
'radial-gradient(rgba(255,255,255,0.07) 1px,transparent 1.5px),',
|
|
121
|
+
'radial-gradient(56rem 38rem at 14% 18%,color-mix(in srgb,var(--brand-primary,#6366f1) 34%,transparent),transparent 70%),',
|
|
122
|
+
'radial-gradient(64rem 44rem at 86% 84%,color-mix(in srgb,var(--brand-accent,var(--brand-primary,#6366f1)) 22%,transparent),transparent 70%);',
|
|
123
|
+
'background-size:26px 26px,auto,auto;',
|
|
124
|
+
'background-color:#0b1120;',
|
|
125
|
+
'color:#fff;',
|
|
126
|
+
'}',
|
|
127
|
+
'body[data-walkthrough-card] .walkthrough-card-bg{',
|
|
128
|
+
'position:absolute;inset:0;',
|
|
129
|
+
'background-size:cover;background-position:center;background-repeat:no-repeat;',
|
|
130
|
+
'z-index:0;transform-origin:center center;',
|
|
131
|
+
// Animation: slow zoom from 1.0 to 1.06 over 6s. Ease-out so most of
|
|
132
|
+
// the motion happens in the first 2s while the eye is still settling
|
|
133
|
+
// on the card. forwards keeps the final scale stuck so the card
|
|
134
|
+
// doesn't snap back on long holds.
|
|
135
|
+
'animation:walkthrough-card-kenburns 6000ms ease-out 0ms forwards;',
|
|
136
|
+
'will-change:transform;',
|
|
137
|
+
'}',
|
|
138
|
+
'body[data-walkthrough-card] .walkthrough-card{',
|
|
139
|
+
'text-align:center;max-width:80%;padding:0 24px;',
|
|
140
|
+
'position:relative;z-index:1;',
|
|
141
|
+
'}',
|
|
142
|
+
'body[data-walkthrough-card] .walkthrough-card-logo{',
|
|
143
|
+
'max-height:84px;max-width:260px;margin:0 auto 28px;',
|
|
144
|
+
'object-fit:contain;display:block;',
|
|
145
|
+
// Entrance: scale 0.92 -> 1.0 + opacity 0 -> 1 over 800ms starting
|
|
146
|
+
// at 200ms after the card mounts. Cubic-bezier matches the brand
|
|
147
|
+
// polish used elsewhere (data-walkthrough-stage zoom curve).
|
|
148
|
+
'opacity:0;transform:scale(0.92);',
|
|
149
|
+
'animation:walkthrough-card-logo-in 800ms cubic-bezier(0.2,0.8,0.2,1) 200ms forwards;',
|
|
150
|
+
'will-change:opacity,transform;',
|
|
151
|
+
'}',
|
|
152
|
+
// Contrast plate behind the logo, applied by writeTitleCards when the
|
|
153
|
+
// logo's ink luminance sits too close to the card_bg image behind it
|
|
154
|
+
// (green logo on a green gradient). The image shows through the logo's
|
|
155
|
+
// transparent pixels, so the plate is just padding + a translucent fill
|
|
156
|
+
// on the <img> itself. content-box keeps the 84px cap on the artwork
|
|
157
|
+
// even when project CSS resets everything to border-box.
|
|
158
|
+
'body[data-walkthrough-card] .walkthrough-card-logo--plate-light,',
|
|
159
|
+
'body[data-walkthrough-card] .walkthrough-card-logo--plate-dark{',
|
|
160
|
+
'box-sizing:content-box;padding:16px 26px;border-radius:18px;',
|
|
161
|
+
'}',
|
|
162
|
+
'body[data-walkthrough-card] .walkthrough-card-logo--plate-light{',
|
|
163
|
+
'background:rgba(255,255,255,0.92);',
|
|
164
|
+
'box-shadow:0 10px 34px rgba(2,6,23,0.22);',
|
|
165
|
+
'}',
|
|
166
|
+
'body[data-walkthrough-card] .walkthrough-card-logo--plate-dark{',
|
|
167
|
+
'background:rgba(11,17,32,0.80);',
|
|
168
|
+
'box-shadow:0 10px 34px rgba(2,6,23,0.35);',
|
|
169
|
+
'}',
|
|
170
|
+
// Explicit colour on title + tagline so project-injected CSS
|
|
171
|
+
// (e.g. Bootstrap `h1 { color: ... }`) can't override via direct
|
|
172
|
+
// type-selector specificity. Body's inherited colour loses to a direct
|
|
173
|
+
// h1 colour declaration; setting it here at (0,2,1) specificity wins.
|
|
174
|
+
'body[data-walkthrough-card] .walkthrough-card-title{',
|
|
175
|
+
'color:#0f172a;',
|
|
176
|
+
'font-size:48px;font-weight:700;margin:0 0 16px;',
|
|
177
|
+
'letter-spacing:-0.02em;line-height:1.1;',
|
|
178
|
+
'}',
|
|
179
|
+
'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-title{',
|
|
180
|
+
'color:#fff;',
|
|
181
|
+
'}',
|
|
182
|
+
// Title text is wrapped in an inline-block span so clip-path can mask
|
|
183
|
+
// it left-to-right without disrupting the surrounding block layout.
|
|
184
|
+
// The wrapper span animates inset clipping; the h1 itself stays clean.
|
|
185
|
+
'body[data-walkthrough-card] .walkthrough-card-title-text{',
|
|
186
|
+
'display:inline-block;',
|
|
187
|
+
'clip-path:inset(0 100% 0 0);',
|
|
188
|
+
'animation:walkthrough-card-title-mask 800ms cubic-bezier(0.2,0.8,0.2,1) 500ms forwards;',
|
|
189
|
+
'will-change:clip-path;',
|
|
190
|
+
'}',
|
|
191
|
+
'body[data-walkthrough-card] .walkthrough-card-tagline{',
|
|
192
|
+
'color:#334155;',
|
|
193
|
+
'font-size:20px;opacity:0.9;margin:0;font-weight:400;line-height:1.4;',
|
|
194
|
+
// Override: start at 0 opacity + 12px down, animate up. The 0.9
|
|
195
|
+
// baseline opacity becomes the animation target via to{opacity:0.9}
|
|
196
|
+
// in the keyframes, keeping the subtle text fade after entrance.
|
|
197
|
+
'opacity:0;transform:translateY(12px);',
|
|
198
|
+
'animation:walkthrough-card-tagline-in 500ms cubic-bezier(0.2,0.8,0.2,1) 1000ms forwards;',
|
|
199
|
+
'will-change:opacity,transform;',
|
|
200
|
+
'}',
|
|
201
|
+
'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-tagline{',
|
|
202
|
+
'color:#cbd5e1;',
|
|
203
|
+
'}',
|
|
204
|
+
// When a bg image is present (the sibling .walkthrough-card-bg div only
|
|
205
|
+
// exists then), give the title/tagline a soft counter-shadow so
|
|
206
|
+
// mid-luminance gradients can't wash the text out; the theme flip in
|
|
207
|
+
// writeTitleCards already handles the clear-cut light/dark cases.
|
|
208
|
+
'body[data-walkthrough-card] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-title,',
|
|
209
|
+
'body[data-walkthrough-card] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-tagline{',
|
|
210
|
+
'text-shadow:0 1px 2px rgba(255,255,255,0.55),0 0 26px rgba(255,255,255,0.45);',
|
|
211
|
+
'}',
|
|
212
|
+
'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-title,',
|
|
213
|
+
'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-tagline{',
|
|
214
|
+
'text-shadow:0 1px 2px rgba(2,6,23,0.60),0 0 26px rgba(2,6,23,0.50);',
|
|
215
|
+
'}',
|
|
216
|
+
// Outro fade-to-black: the recorder sets data-walkthrough-fade-out=1
|
|
217
|
+
// ~400ms before stopping recording. A black overlay fades IN over the
|
|
218
|
+
// card (rather than fading the body out, which would flash the white
|
|
219
|
+
// html background on dark cards), producing a clean tail on both themes.
|
|
220
|
+
'body[data-walkthrough-card="outro"]::after{',
|
|
221
|
+
"content:'';position:fixed;top:0;left:0;right:0;bottom:0;",
|
|
222
|
+
'background:#000;opacity:0;pointer-events:none;z-index:9998;',
|
|
223
|
+
'}',
|
|
224
|
+
'body[data-walkthrough-card="outro"][data-walkthrough-fade-out="1"]::after{',
|
|
225
|
+
'animation:walkthrough-card-fade-out 400ms ease-in 0ms forwards;',
|
|
226
|
+
'}',
|
|
227
|
+
'@keyframes walkthrough-card-kenburns{',
|
|
228
|
+
'0%{transform:scale(1);}',
|
|
229
|
+
'100%{transform:scale(1.06);}',
|
|
230
|
+
'}',
|
|
231
|
+
'@keyframes walkthrough-card-logo-in{',
|
|
232
|
+
'0%{opacity:0;transform:scale(0.92);}',
|
|
233
|
+
'100%{opacity:1;transform:scale(1);}',
|
|
234
|
+
'}',
|
|
235
|
+
'@keyframes walkthrough-card-title-mask{',
|
|
236
|
+
'0%{clip-path:inset(0 100% 0 0);}',
|
|
237
|
+
'100%{clip-path:inset(0 0% 0 0);}',
|
|
238
|
+
'}',
|
|
239
|
+
'@keyframes walkthrough-card-tagline-in{',
|
|
240
|
+
'0%{opacity:0;transform:translateY(12px);}',
|
|
241
|
+
'100%{opacity:0.9;transform:translateY(0);}',
|
|
242
|
+
'}',
|
|
243
|
+
'@keyframes walkthrough-card-fade-out{',
|
|
244
|
+
'0%{opacity:0;}',
|
|
245
|
+
'100%{opacity:1;}',
|
|
246
|
+
'}',
|
|
247
|
+
// ── Radial-wipe transition into step_0 ────────────────────────────
|
|
248
|
+
// When the recorder navigates from the intro card to step_0, the new
|
|
249
|
+
// page carries data-walkthrough-wipe-in=1 on its body. The stage
|
|
250
|
+
// wrapper (data-walkthrough-stage) gets a clip-path that expands from
|
|
251
|
+
// a centred dot to a full-viewport circle over 700ms — the brand-wash
|
|
252
|
+
// overlay underneath fades on the existing schedule, so the user sees
|
|
253
|
+
// the brand wash dissolve as the mockup emerges through an expanding
|
|
254
|
+
// circle. Only step_0 carries this attribute; step-to-step transitions
|
|
255
|
+
// keep the existing cross-fade.
|
|
256
|
+
'body[data-walkthrough-wipe-in="1"] [data-walkthrough-stage]{',
|
|
257
|
+
'clip-path:circle(0% at 50% 50%);',
|
|
258
|
+
'animation:walkthrough-stage-wipe-in 700ms cubic-bezier(0.2,0.8,0.2,1) 0ms forwards;',
|
|
259
|
+
'will-change:clip-path;',
|
|
260
|
+
'}',
|
|
261
|
+
'@keyframes walkthrough-stage-wipe-in{',
|
|
262
|
+
'0%{clip-path:circle(0% at 50% 50%);}',
|
|
263
|
+
'100%{clip-path:circle(150% at 50% 50%);}',
|
|
264
|
+
'}',
|
|
265
|
+
'</style>',
|
|
266
|
+
].join('');
|
|
267
|
+
|
|
268
|
+
// Tiny inline loader that triggers the cross-fade fade-in once the document is
|
|
269
|
+
// parsed. Without this the body::before overlay stays opaque (default state)
|
|
270
|
+
// and the page would never become visible. Placed in <head> so it runs as
|
|
271
|
+
// early as possible — the readyState check covers the case where the script
|
|
272
|
+
// parses after DOMContentLoaded already fired (rare, but possible when
|
|
273
|
+
// scripts above it block).
|
|
274
|
+
const WALKTHROUGH_LOADER = [
|
|
275
|
+
'<script data-walkthrough-loader="1">',
|
|
276
|
+
"(function(){var g=function(){document.documentElement.classList.add('walkthrough-loaded');};",
|
|
277
|
+
"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',g);}else{g();}})();",
|
|
278
|
+
'</script>',
|
|
279
|
+
].join('');
|
|
280
|
+
|
|
281
|
+
// Stage wrapper around mockup content. The recorder controls its transform to
|
|
282
|
+
// zoom in on interactions (clicks, typing fields). The ghost-cursor element is
|
|
283
|
+
// NOT inside the stage — it's a direct child of body — so it stays stable while
|
|
284
|
+
// the stage scales. Keeping transform-origin at 0 0 means the recorder fully
|
|
285
|
+
// controls the focal point via inline style updates.
|
|
286
|
+
const STAGE_OPEN = [
|
|
287
|
+
'<div data-walkthrough-stage="1" style="',
|
|
288
|
+
'display:block;',
|
|
289
|
+
// min-height ensures the stage fills the visible viewport even when the
|
|
290
|
+
// mockup content is short — without it, a 200px-tall card on an otherwise
|
|
291
|
+
// empty page would leave ~500px of blank body background at the bottom of
|
|
292
|
+
// the 720p video. The mockup owns the full 720px frame (no caption
|
|
293
|
+
// chrome is reserved), so the content box is exactly the viewport height.
|
|
294
|
+
'min-height:100vh;',
|
|
295
|
+
'transform:scale(1);',
|
|
296
|
+
'transform-origin:0 0;',
|
|
297
|
+
'transition:transform 350ms cubic-bezier(0.4,0,0.2,1);',
|
|
298
|
+
// No will-change here: it pins the stage's raster at scale 1, so text
|
|
299
|
+
// blurs when the camera zooms in. transform:scale(1) alone keeps the
|
|
300
|
+
// stage the containing block the root-offset bridge relies on.
|
|
301
|
+
'">',
|
|
302
|
+
].join('');
|
|
303
|
+
const STAGE_CLOSE = '</div>';
|
|
304
|
+
|
|
305
|
+
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
const log = message => process.stderr.write(message + '\n');
|
|
308
|
+
|
|
309
|
+
/** Truthiness as the branding/manifest checks expect: empty objects and arrays are false. */
|
|
310
|
+
function truthy(value) {
|
|
311
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
312
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
|
|
313
|
+
return Boolean(value);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
317
|
+
|
|
318
|
+
/** Replace every occurrence of a literal string (no `$` pattern handling). */
|
|
319
|
+
function replaceAllLiteral(text, search, replacement) {
|
|
320
|
+
return text.split(search).join(replacement);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Replace the first occurrence of a literal string (no `$` pattern handling). */
|
|
324
|
+
function replaceFirstLiteral(text, search, replacement) {
|
|
325
|
+
const at = text.indexOf(search);
|
|
326
|
+
return at < 0 ? text : text.slice(0, at) + replacement + text.slice(at + search.length);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function escapeRegExp(text) {
|
|
330
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isFile(file) {
|
|
334
|
+
try { return fs.statSync(file).isFile(); } catch { return false; }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isDir(dir) {
|
|
338
|
+
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readFileOrEmpty(file) {
|
|
342
|
+
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function readJson(file) {
|
|
346
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Split a directory into subdirectories and files; symlinked directories are listed but never followed. */
|
|
350
|
+
function listDir(dir) {
|
|
351
|
+
let entries;
|
|
352
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
353
|
+
catch { return { dirs: [], files: [] }; }
|
|
354
|
+
const dirs = [];
|
|
355
|
+
const files = [];
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
let directory = entry.isDirectory();
|
|
358
|
+
if (entry.isSymbolicLink()) {
|
|
359
|
+
try { directory = fs.statSync(path.join(dir, entry.name)).isDirectory(); } catch { directory = false; }
|
|
360
|
+
if (directory) continue;
|
|
361
|
+
}
|
|
362
|
+
(directory ? dirs : files).push(entry.name);
|
|
363
|
+
}
|
|
364
|
+
return { dirs, files };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── Inputs ──────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
function loadCss(cssFile) {
|
|
370
|
+
if (!cssFile || !isFile(cssFile)) return null;
|
|
371
|
+
try {
|
|
372
|
+
const content = fs.readFileSync(cssFile, 'utf8').trim();
|
|
373
|
+
return content || null;
|
|
374
|
+
} catch { return null; }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function loadImages(imagesJson) {
|
|
378
|
+
if (!isFile(imagesJson)) return {};
|
|
379
|
+
try { return readJson(imagesJson).images ?? {}; } catch { return {}; }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Load generated_images.json and return id -> data URI.
|
|
384
|
+
*
|
|
385
|
+
* Generated files deliberately stay separate from the project image manifest:
|
|
386
|
+
* .rtfm/images_base64.json is signature-cached from repository assets, while
|
|
387
|
+
* generated content is output-scoped and cacheable independently.
|
|
388
|
+
*/
|
|
389
|
+
function loadGeneratedImages(generatedImagesJson) {
|
|
390
|
+
if (!generatedImagesJson) return {};
|
|
391
|
+
if (!isFile(generatedImagesJson)) {
|
|
392
|
+
log(`WARNING: generated image manifest not found: ${generatedImagesJson}`);
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
let manifest;
|
|
396
|
+
try { manifest = readJson(generatedImagesJson); }
|
|
397
|
+
catch (error) {
|
|
398
|
+
log(`WARNING: cannot read generated image manifest: ${error.message}`);
|
|
399
|
+
return {};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const resolved = {};
|
|
403
|
+
const baseDir = path.dirname(path.resolve(generatedImagesJson));
|
|
404
|
+
const assets = truthy(manifest?.assets) && isObject(manifest.assets) ? manifest.assets : {};
|
|
405
|
+
for (const [assetId, entry] of Object.entries(assets)) {
|
|
406
|
+
if (!isObject(entry)) continue;
|
|
407
|
+
const relPath = entry.path;
|
|
408
|
+
if (typeof relPath !== 'string') continue;
|
|
409
|
+
const assetPath = path.resolve(baseDir, relPath);
|
|
410
|
+
const inside = assetPath === baseDir || assetPath.startsWith(baseDir + path.sep);
|
|
411
|
+
if (!inside || !isFile(assetPath)) {
|
|
412
|
+
log(`WARNING: generated asset '${assetId}' is missing or outside its output directory`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
let encoded;
|
|
416
|
+
try { encoded = fs.readFileSync(assetPath).toString('base64'); }
|
|
417
|
+
catch (error) {
|
|
418
|
+
log(`WARNING: cannot read generated asset '${assetId}': ${error.message}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const mime = entry.mime || (assetPath.endsWith('.svg') ? 'image/svg+xml' : 'image/png');
|
|
422
|
+
resolved[assetId] = `data:${mime};base64,${encoded}`;
|
|
423
|
+
}
|
|
424
|
+
return resolved;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Load actions.json steps and merge in matching content from article.json
|
|
429
|
+
* (located next to actions.json) so the title cards can read the article
|
|
430
|
+
* title / introduction / summary at inject time, and the step count is known.
|
|
431
|
+
* Returns { steps, article } or null.
|
|
432
|
+
*/
|
|
433
|
+
function loadWalkthroughSteps(actionsJson) {
|
|
434
|
+
if (!actionsJson) return null;
|
|
435
|
+
if (!isFile(actionsJson)) {
|
|
436
|
+
log(`WARNING: --walkthrough file not found: ${actionsJson}`);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
let data;
|
|
440
|
+
try { data = readJson(actionsJson); }
|
|
441
|
+
catch (error) {
|
|
442
|
+
log(`WARNING: failed to read ${actionsJson}: ${error.message}`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const steps = truthy(data.steps) ? data.steps : [];
|
|
447
|
+
|
|
448
|
+
// Try to read article.json from the same directory so we can attach
|
|
449
|
+
// per-step content + article-level title/intro/summary for title cards.
|
|
450
|
+
let article = {};
|
|
451
|
+
const articlePath = path.join(path.dirname(actionsJson) || '.', 'article.json');
|
|
452
|
+
if (isFile(articlePath)) {
|
|
453
|
+
try { article = readJson(articlePath) || {}; }
|
|
454
|
+
catch (error) { log(`WARNING: failed to read ${articlePath}: ${error.message}`); }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const articleSteps = truthy(article.steps) ? article.steps : [];
|
|
458
|
+
const merged = steps.map((step, i) => {
|
|
459
|
+
const mergedStep = { ...step };
|
|
460
|
+
if (i < articleSteps.length && isObject(articleSteps[i])) {
|
|
461
|
+
for (const key of ['title', 'content']) {
|
|
462
|
+
if (!truthy(mergedStep[key]) && truthy(articleSteps[i][key])) mergedStep[key] = articleSteps[i][key];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return mergedStep;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
steps: merged,
|
|
470
|
+
article: { title: article.title, introduction: article.introduction, summary: article.summary },
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── CSS asset inlining ──────────────────────────────────────────────────────
|
|
475
|
+
// Compiled app CSS often references repo assets by relative url() — e.g.
|
|
476
|
+
// .block-pattern-green { background-image: url('img/block-pattern-green.png') }.
|
|
477
|
+
// Those paths can never resolve from a file:// mockup, so the styled region
|
|
478
|
+
// renders blank and the authoring model "fills in" the missing visual with an
|
|
479
|
+
// invented gradient. Inline them as data URIs so the real asset renders.
|
|
480
|
+
|
|
481
|
+
const CSS_ASSET_MAX_BYTES = 1_500_000;
|
|
482
|
+
const CSS_ASSET_MIME = {
|
|
483
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
484
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
485
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf',
|
|
486
|
+
};
|
|
487
|
+
const ASSET_WALK_SKIP_DIRS = new Set(['node_modules', 'tmp', 'log', 'output', 'coverage', 'dist', 'build']);
|
|
488
|
+
|
|
489
|
+
const CSS_URL_RE = /url\(\s*(['"]?)([^)'"]+)\1\s*\)/g;
|
|
490
|
+
|
|
491
|
+
/** One walk of the project tree resolving wanted basenames → absolute paths. */
|
|
492
|
+
function findProjectAssets(projectDir, basenames) {
|
|
493
|
+
const wanted = new Set([...basenames].map(name => name.toLowerCase()));
|
|
494
|
+
const found = {};
|
|
495
|
+
const visit = dir => {
|
|
496
|
+
const { dirs, files } = listDir(dir);
|
|
497
|
+
for (const file of files) {
|
|
498
|
+
const low = file.toLowerCase();
|
|
499
|
+
if (wanted.has(low) && !(low in found)) found[low] = path.join(dir, file);
|
|
500
|
+
}
|
|
501
|
+
if (Object.keys(found).length === wanted.size) return true;
|
|
502
|
+
for (const name of dirs) {
|
|
503
|
+
if (ASSET_WALK_SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
|
504
|
+
if (visit(path.join(dir, name))) return true;
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
};
|
|
508
|
+
visit(projectDir);
|
|
509
|
+
return found;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Rewrite relative url() refs in cssPath to data URIs (matched by basename
|
|
514
|
+
* anywhere in the project tree). Idempotent — data:/http(s) refs are skipped.
|
|
515
|
+
* Returns the number of refs inlined.
|
|
516
|
+
*/
|
|
517
|
+
function inlineCssAssetUrls(cssPath, projectDir) {
|
|
518
|
+
let css;
|
|
519
|
+
try { css = fs.readFileSync(cssPath, 'utf8'); } catch { return 0; }
|
|
520
|
+
|
|
521
|
+
const refs = new Map();
|
|
522
|
+
for (const match of css.matchAll(CSS_URL_RE)) {
|
|
523
|
+
const target = match[2].trim();
|
|
524
|
+
if (['data:', 'http://', 'https://', '//', '#'].some(prefix => target.startsWith(prefix))) continue;
|
|
525
|
+
const base = path.posix.basename(target.split('?')[0].split('#')[0]);
|
|
526
|
+
const ext = path.extname(base).toLowerCase();
|
|
527
|
+
if (Object.hasOwn(CSS_ASSET_MIME, ext)) refs.set(match[0], { base, ext });
|
|
528
|
+
}
|
|
529
|
+
if (!refs.size) return 0;
|
|
530
|
+
|
|
531
|
+
const lookup = findProjectAssets(projectDir, new Set([...refs.values()].map(ref => ref.base)));
|
|
532
|
+
const replacements = new Map();
|
|
533
|
+
for (const [raw, { base, ext }] of refs) {
|
|
534
|
+
const file = lookup[base.toLowerCase()];
|
|
535
|
+
if (!file) continue;
|
|
536
|
+
let data;
|
|
537
|
+
try {
|
|
538
|
+
const size = fs.statSync(file).size;
|
|
539
|
+
if (!(size > 0 && size <= CSS_ASSET_MAX_BYTES)) continue;
|
|
540
|
+
data = fs.readFileSync(file).toString('base64');
|
|
541
|
+
} catch { continue; }
|
|
542
|
+
replacements.set(raw, `url(data:${CSS_ASSET_MIME[ext]};base64,${data})`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!replacements.size) return 0;
|
|
546
|
+
for (const [raw, replacement] of replacements) css = replaceAllLiteral(css, raw, replacement);
|
|
547
|
+
fs.writeFileSync(cssPath, css);
|
|
548
|
+
return replacements.size;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ─── Icon font embedding ─────────────────────────────────────────────────────
|
|
552
|
+
// Icon fonts usually arrive via an app bundle or CDN, neither of which exists
|
|
553
|
+
// in a self-contained mockup (renders block external requests). When step HTML
|
|
554
|
+
// uses fa-*/bi-* classes, fetch the icon CSS once, inline its woff2 fonts as
|
|
555
|
+
// data URIs, and cache the result in the project cache dir so later runs skip
|
|
556
|
+
// the fetch.
|
|
557
|
+
|
|
558
|
+
const ICON_FONT_SOURCES = {
|
|
559
|
+
fontawesome: {
|
|
560
|
+
detect: /class=["'][^"']*\bfa[srlbd]?\s+fa-|class=["'][^"']*\bfa-[a-z]/,
|
|
561
|
+
cssUrl: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css',
|
|
562
|
+
},
|
|
563
|
+
'bootstrap-icons': {
|
|
564
|
+
detect: /class=["'][^"']*\bbi[\s-]/,
|
|
565
|
+
cssUrl: 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
|
|
566
|
+
},
|
|
567
|
+
'material-icons': {
|
|
568
|
+
// Ligature-based: <span class="material-icons">home</span>
|
|
569
|
+
detect: /class=["'][^"']*\bmaterial-icons/,
|
|
570
|
+
cssUrl: 'https://fonts.googleapis.com/icon?family=Material+Icons',
|
|
571
|
+
},
|
|
572
|
+
'material-symbols': {
|
|
573
|
+
// <span class="material-symbols-outlined">home</span> (+rounded/sharp)
|
|
574
|
+
detect: /class=["'][^"']*\bmaterial-symbols-/,
|
|
575
|
+
cssUrl: 'https://fonts.googleapis.com/css2'
|
|
576
|
+
+ '?family=Material+Symbols+Outlined'
|
|
577
|
+
+ '&family=Material+Symbols+Rounded'
|
|
578
|
+
+ '&family=Material+Symbols+Sharp',
|
|
579
|
+
},
|
|
580
|
+
boxicons: {
|
|
581
|
+
// <i class="bx bx-home">, bxs- (solid), bxl- (logos)
|
|
582
|
+
detect: /class=["'][^"']*\bbx\s+bx[sl]?-/,
|
|
583
|
+
cssUrl: 'https://cdn.jsdelivr.net/npm/boxicons@2.1.4/css/boxicons.min.css',
|
|
584
|
+
},
|
|
585
|
+
remixicon: {
|
|
586
|
+
// <i class="ri-home-line">
|
|
587
|
+
detect: /class=["'][^"']*\bri-[a-z0-9-]+/,
|
|
588
|
+
cssUrl: 'https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css',
|
|
589
|
+
},
|
|
590
|
+
glyphicons: {
|
|
591
|
+
// Bootstrap 3 legacy: <span class="glyphicon glyphicon-cog">. Only
|
|
592
|
+
// ships inside the full BS3 stylesheet, so filter to glyphicon rules —
|
|
593
|
+
// injecting all of Bootstrap 3 would clobber the project's real CSS.
|
|
594
|
+
detect: /class=["'][^"']*\bglyphicon\b/,
|
|
595
|
+
cssUrl: 'https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css',
|
|
596
|
+
selectorFilter: 'glyphicon',
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const ICON_FONTS_FILENAME = 'icon_fonts.css';
|
|
601
|
+
|
|
602
|
+
async function fetchBytes(url, timeoutMs = 20000) {
|
|
603
|
+
// Browser UA: Google Fonts sniffs the UA and only serves woff2 (and the
|
|
604
|
+
// ligature-capable CSS) to modern browsers — an unknown agent gets ttf.
|
|
605
|
+
const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
|
606
|
+
+ '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
607
|
+
const response = await fetch(url, { headers: { 'User-Agent': ua }, signal: AbortSignal.timeout(timeoutMs) });
|
|
608
|
+
if (!response.ok) throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
609
|
+
return Buffer.from(await response.arrayBuffer());
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Keep only @font-face blocks and rules whose selector mentions the
|
|
614
|
+
* substring. Good enough for minified framework CSS — used to extract the
|
|
615
|
+
* glyphicon subset from Bootstrap 3 without importing its layout rules.
|
|
616
|
+
*/
|
|
617
|
+
function filterCssRules(css, selectorSubstring) {
|
|
618
|
+
const kept = [];
|
|
619
|
+
for (const match of css.matchAll(/(@font-face\s*\{[^{}]*\})|([^{}@]+)\{([^{}]*)\}/g)) {
|
|
620
|
+
if (match[1]) kept.push(match[1]);
|
|
621
|
+
else if ((match[2] ?? '').includes(selectorSubstring)) kept.push(`${match[2].trim()}{${match[3]}}`);
|
|
622
|
+
}
|
|
623
|
+
return kept.join('\n');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Fetch an icon CSS file and inline its woff2 fonts as data URIs. Other
|
|
628
|
+
* font formats are rewritten to absolute URLs (blocked at render time, but
|
|
629
|
+
* woff2 comes first in every src list so Chromium never needs them).
|
|
630
|
+
*/
|
|
631
|
+
async function fetchIconCssWithFonts(cssUrl, selectorFilter) {
|
|
632
|
+
let css = (await fetchBytes(cssUrl)).toString('utf8');
|
|
633
|
+
if (selectorFilter) css = filterCssRules(css, selectorFilter);
|
|
634
|
+
|
|
635
|
+
const replacements = [];
|
|
636
|
+
for (const match of css.matchAll(CSS_URL_RE)) {
|
|
637
|
+
const target = match[2].trim();
|
|
638
|
+
if (target.startsWith('data:')) {
|
|
639
|
+
replacements.push(match[0]);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const absolute = new URL(target, cssUrl).href;
|
|
643
|
+
if (target.split('?')[0].split('#')[0].endsWith('.woff2')) {
|
|
644
|
+
try {
|
|
645
|
+
replacements.push(`url(data:font/woff2;base64,${(await fetchBytes(absolute)).toString('base64')})`);
|
|
646
|
+
} catch {
|
|
647
|
+
replacements.push(`url(${absolute})`);
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
replacements.push(`url(${absolute})`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
let i = 0;
|
|
654
|
+
return css.replace(CSS_URL_RE, () => replacements[i++]);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Detect icon-font usage across article/walkthrough mockups; build/reuse a cached
|
|
659
|
+
* icon_fonts.css with embedded fonts. Returns the local filename to link
|
|
660
|
+
* from the head block, or null when no icon classes are present.
|
|
661
|
+
*/
|
|
662
|
+
async function ensureIconFonts(htmlDir, cacheDir) {
|
|
663
|
+
let combined = '';
|
|
664
|
+
for (const fname of fs.readdirSync(htmlDir)) {
|
|
665
|
+
if ((fname.startsWith('step_') || fname.startsWith('block_')) && fname.endsWith('.html')) {
|
|
666
|
+
try { combined += fs.readFileSync(path.join(htmlDir, fname), 'utf8'); } catch { /* skip */ }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const needed = Object.entries(ICON_FONT_SOURCES).filter(([, spec]) => spec.detect.test(combined)).map(([name]) => name);
|
|
670
|
+
if (!needed.length) return null;
|
|
671
|
+
|
|
672
|
+
const cachePath = cacheDir ? path.join(cacheDir, ICON_FONTS_FILENAME) : null;
|
|
673
|
+
let existing = cachePath && isFile(cachePath) ? readFileOrEmpty(cachePath) : '';
|
|
674
|
+
const have = new Set([...existing.matchAll(/\/\* rtfm-icon-set: (\S+) \*\//g)].map(match => match[1]));
|
|
675
|
+
|
|
676
|
+
for (const name of needed.filter(name => !have.has(name)).sort()) {
|
|
677
|
+
try {
|
|
678
|
+
const spec = ICON_FONT_SOURCES[name];
|
|
679
|
+
const css = await fetchIconCssWithFonts(spec.cssUrl, spec.selectorFilter);
|
|
680
|
+
existing += `\n/* rtfm-icon-set: ${name} */\n${css}`;
|
|
681
|
+
log(` embedded icon font set: ${name}`);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
log(`WARNING: could not fetch icon font set ${name}: ${error.message} (renderer glyph fallback will be used)`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!existing.trim()) return null;
|
|
688
|
+
if (cachePath) {
|
|
689
|
+
try { fs.writeFileSync(cachePath, existing); } catch { /* cache is optional */ }
|
|
690
|
+
}
|
|
691
|
+
fs.writeFileSync(path.join(htmlDir, ICON_FONTS_FILENAME), existing);
|
|
692
|
+
return ICON_FONTS_FILENAME;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Load branding.json. Returns null if missing, unreadable, or empty {}. */
|
|
696
|
+
function loadBranding(brandingJson) {
|
|
697
|
+
if (!brandingJson || !isFile(brandingJson)) return null;
|
|
698
|
+
try {
|
|
699
|
+
const data = readJson(brandingJson);
|
|
700
|
+
return truthy(data) ? data : null;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
log(`WARNING: failed to read ${brandingJson}: ${error.message}`);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Extract the first font family name from branding.google_fonts entries,
|
|
709
|
+
* e.g. https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap
|
|
710
|
+
* -> "Inter". Descriptor objects contribute their `family`. Used to put the
|
|
711
|
+
* brand font on the intro/outro title cards. Returns null when none parses.
|
|
712
|
+
*/
|
|
713
|
+
function firstGoogleFontFamily(branding) {
|
|
714
|
+
for (const item of (truthy(branding?.google_fonts) ? branding.google_fonts : [])) {
|
|
715
|
+
let family = null;
|
|
716
|
+
if (typeof item === 'string') {
|
|
717
|
+
const match = item.match(/[?&]family=([^:&]+)/);
|
|
718
|
+
if (match) family = match[1].replaceAll('+', ' ').replaceAll('%20', ' ').trim();
|
|
719
|
+
} else if (isObject(item) && typeof item.family === 'string') {
|
|
720
|
+
family = item.family.trim();
|
|
721
|
+
}
|
|
722
|
+
if (family) return family.replaceAll("'", '');
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Normalise a google_fonts entry to a fonts.googleapis.com CSS URL.
|
|
729
|
+
*
|
|
730
|
+
* Accepts either a plain URL string (used verbatim) or a descriptor object like
|
|
731
|
+
* {"family": "Plus Jakarta Sans", "weights": ["500","600","700"], "source": ...}
|
|
732
|
+
* (what detect-project records for a next/font/google import), which is turned
|
|
733
|
+
* into https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700&display=swap.
|
|
734
|
+
* Returns null for anything unusable.
|
|
735
|
+
*/
|
|
736
|
+
function fontDescriptorToUrl(item) {
|
|
737
|
+
if (typeof item === 'string') {
|
|
738
|
+
const s = item.trim();
|
|
739
|
+
if (!s) return null;
|
|
740
|
+
// A bare family name ("Inter") is not a URL — emitting it verbatim as an
|
|
741
|
+
// href produces a broken <link href="Inter"> tag. Compose the css2 URL.
|
|
742
|
+
if (!/^(https?:)?\/\//.test(s) && !s.includes('/')) {
|
|
743
|
+
return `https://fonts.googleapis.com/css2?family=${s.replaceAll(' ', '+')}&display=swap`;
|
|
744
|
+
}
|
|
745
|
+
return s;
|
|
746
|
+
}
|
|
747
|
+
if (isObject(item)) {
|
|
748
|
+
const family = String(item.family || '').trim();
|
|
749
|
+
if (!family) return null;
|
|
750
|
+
const famQ = family.replaceAll(' ', '+');
|
|
751
|
+
const weights = (truthy(item.weights) ? item.weights : []).map(w => String(w).trim()).filter(Boolean);
|
|
752
|
+
if (weights.length) return `https://fonts.googleapis.com/css2?family=${famQ}:wght@${weights.join(';')}&display=swap`;
|
|
753
|
+
return `https://fonts.googleapis.com/css2?family=${famQ}&display=swap`;
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const BODY_SELECTOR_RE = /^(?:html\s+)?body((?:[.:#[][^\s>+~,{]*)*)$/;
|
|
759
|
+
const BG_DECL_RE = /(?:^|;)\s*(background(?:-color|-image)?\s*:[^;]+)/gi;
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Bridge body-scoped backgrounds into the desktop window interior.
|
|
763
|
+
*
|
|
764
|
+
* Real apps paint their canvas on `body` (often gated by a theme class:
|
|
765
|
+
* `body.theme-dark { background: … }`). Inside a framed desktop mockup the
|
|
766
|
+
* app lives in `.desktop-window-content`, and `body` is the surrounding
|
|
767
|
+
* stage — so those backgrounds never reach the window interior and a themed
|
|
768
|
+
* app renders on the frame's default white (dark-theme text becomes
|
|
769
|
+
* white-on-white "voids"). Re-emit every body-targeting background rule
|
|
770
|
+
* against `.desktop-window-content`, preserving the original body selector
|
|
771
|
+
* prefix so theme-class gating still applies.
|
|
772
|
+
*/
|
|
773
|
+
function desktopWindowBgBridge(cssText) {
|
|
774
|
+
if (!cssText) return '';
|
|
775
|
+
const bridged = [];
|
|
776
|
+
for (const match of cssText.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
777
|
+
const [, rawSel, body] = match;
|
|
778
|
+
if (!body.includes('background')) continue;
|
|
779
|
+
for (let sel of rawSel.split(',')) {
|
|
780
|
+
sel = sel.trim().split('}').at(-1).trim(); // drop leading at-rule tails
|
|
781
|
+
const sm = sel.match(BODY_SELECTOR_RE);
|
|
782
|
+
if (!sm) continue;
|
|
783
|
+
const decls = [...body.matchAll(BG_DECL_RE)].map(m => m[1].trim().replace(/;+$/, ''));
|
|
784
|
+
if (!decls.length) continue;
|
|
785
|
+
const suffix = sm[1] || '';
|
|
786
|
+
bridged.push(`body${suffix} .desktop-window-content { ${decls.join('; ')}; }`);
|
|
787
|
+
// Modal surfaces get the same app canvas colour — an app's own modal
|
|
788
|
+
// CSS is often JS-injected/unbuilt, and a transparent dialog with the
|
|
789
|
+
// page bleeding through is worse than a flat canvas-coloured card.
|
|
790
|
+
bridged.push(`body${suffix} .desktop-modal { ${decls.join('; ')}; }`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (!bridged.length) return '';
|
|
794
|
+
return '/* window-bg bridge: body-scoped app backgrounds re-emitted onto the\n'
|
|
795
|
+
+ ' framed window interior (generated by inject_assets.js) */\n' + bridged.join('\n');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const TRIVIAL_OFFSET_RE = /^(?:0(?:\.0+)?(?:px|rem|em|%|vh|vw)?|[12](?:\.\d+)?px|initial|unset|inherit|revert|auto)$/i;
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Bridge root-level fixed-chrome offsets into the walkthrough stage.
|
|
802
|
+
*
|
|
803
|
+
* The zoom stage carries `transform: scale(1)`, which makes it
|
|
804
|
+
* the CONTAINING BLOCK for position:fixed descendants (CSS spec) — so an app's
|
|
805
|
+
* fixed top bar anchors to the stage box instead of the viewport. Meanwhile
|
|
806
|
+
* the offset that keeps that bar off the content lives on the ROOT elements
|
|
807
|
+
* (`html.wp-toolbar { padding-top: 32px }`, Bootstrap's body padding), which
|
|
808
|
+
* sit OUTSIDE the stage: the stage is pushed down by the offset, the captured
|
|
809
|
+
* bar renders at the stage's top edge, and the bar overlaps the first slice
|
|
810
|
+
* of content with an empty band above it. Articles have no stage and are
|
|
811
|
+
* immune — this is walkthrough-only geometry.
|
|
812
|
+
*
|
|
813
|
+
* Fix: replicate the viewport geometry inside the stage. Zero each matching
|
|
814
|
+
* root-level offset and give [data-walkthrough-stage] the same padding-top,
|
|
815
|
+
* so the captured bar paints in the stage's padding band exactly where the
|
|
816
|
+
* real page paints it, and zooming scales bar + content coherently. Only
|
|
817
|
+
* offsets whose selector class is actually present on this step's <html>/
|
|
818
|
+
* <body> are bridged; var() expressions are kept verbatim (they resolve in
|
|
819
|
+
* the document). Returns '' when the step has no such offsets — the common
|
|
820
|
+
* case, leaving output byte-identical.
|
|
821
|
+
*/
|
|
822
|
+
function fixedOffsetBridgeCss(html, cssText) {
|
|
823
|
+
if (!html || !cssText) return '';
|
|
824
|
+
const tokens = {};
|
|
825
|
+
for (const root of ['html', 'body']) {
|
|
826
|
+
const match = html.match(new RegExp(`<${root}[^>]*\\bclass\\s*=\\s*"([^"]*)"`, 'i'));
|
|
827
|
+
tokens[root] = new Set((match ? match[1] : '').split(/\s+/).filter(Boolean));
|
|
828
|
+
}
|
|
829
|
+
if (!tokens.html.size && !tokens.body.size) return '';
|
|
830
|
+
const offsets = new Map(); // "root.cls" -> first non-trivial padding-top expression
|
|
831
|
+
for (const match of cssText.matchAll(/([^{};]+)\{([^{}]*)\}/g)) {
|
|
832
|
+
const [, sel, decls] = match;
|
|
833
|
+
const pt = decls.match(/(?:^|;)\s*padding-top\s*:\s*([^;]+)/i);
|
|
834
|
+
if (!pt) continue;
|
|
835
|
+
const value = pt[1].replaceAll('!important', '').trim();
|
|
836
|
+
if (TRIVIAL_OFFSET_RE.test(value)) continue;
|
|
837
|
+
for (const sm of sel.matchAll(/(?:^|[,\s])(html|body)\.([A-Za-z0-9_-]+)/g)) {
|
|
838
|
+
const [, root, cls] = sm;
|
|
839
|
+
if (tokens[root].has(cls) && !offsets.has(`${root}.${cls}`)) offsets.set(`${root}.${cls}`, value);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (!offsets.size) return '';
|
|
843
|
+
const zeroRules = [...offsets.keys()].map(key => `${key}{padding-top:0 !important;}`).join('');
|
|
844
|
+
const exprs = [...new Set(offsets.values())];
|
|
845
|
+
const total = exprs.length === 1 ? exprs[0] : 'calc(' + exprs.join(' + ') + ')';
|
|
846
|
+
return '<style data-rtfm-offset-bridge="1">'
|
|
847
|
+
+ '/* root-offset bridge: the zoom stage captures position:fixed, so the '
|
|
848
|
+
+ 'root-level chrome offset is re-applied inside the stage */'
|
|
849
|
+
+ `${zeroRules}[data-walkthrough-stage]{padding-top:${total};}`
|
|
850
|
+
+ '</style>';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Build the HTML snippet that replaces <!-- INJECT_CSS --> in each mockup.
|
|
855
|
+
*
|
|
856
|
+
* Without branding: <style> if cssContent is given, else bare Tailwind CDN.
|
|
857
|
+
* With branding, layers in compiled CSS / framework CDN / Google Fonts /
|
|
858
|
+
* external stylesheets / :root vars in the documented order.
|
|
859
|
+
*
|
|
860
|
+
* Tailwind projects always get the CDN even when compiled_css_path is set.
|
|
861
|
+
* The compiled build is purged — it only contains classes present in the real
|
|
862
|
+
* project templates. Mockup markup often uses additional utility classes that
|
|
863
|
+
* were never in the source, so the CDN is needed as a complete-coverage
|
|
864
|
+
* companion. The compiled CSS loads first so its component/override rules still
|
|
865
|
+
* take precedence over CDN-generated utilities. A local JIT mockup.css, when
|
|
866
|
+
* present, replaces the Play CDN (reliable + offline).
|
|
867
|
+
*/
|
|
868
|
+
function buildCssHeadBlock(cssContent, branding, { jitCss = null, twHint = false, extraCss = null } = {}) {
|
|
869
|
+
if (!branding) return cssContent ? `<style>\n${cssContent}\n</style>` : TAILWIND_CDN;
|
|
870
|
+
|
|
871
|
+
const parts = [];
|
|
872
|
+
const framework = String(branding.framework || '').toLowerCase();
|
|
873
|
+
const compiledCssPath = branding.compiled_css_path;
|
|
874
|
+
const frameworkCdn = branding.framework_cdn;
|
|
875
|
+
const isTailwindFamily = framework.includes('tailwind') || ['', 'none', 'custom'].includes(framework) || twHint;
|
|
876
|
+
|
|
877
|
+
// 1. Compiled CSS first (highest fidelity — same bytes the app actually serves)
|
|
878
|
+
if (truthy(compiledCssPath)) parts.push(`<link rel="stylesheet" href="${compiledCssPath}">`);
|
|
879
|
+
|
|
880
|
+
// 1b. Render-time JIT CSS (mockup.css) — locally compiled from THIS mockup's
|
|
881
|
+
// markup via the project's cached Tailwind recipe, so it contains exactly
|
|
882
|
+
// the classes the mockup uses (arbitrary values, responsive variants, plugin
|
|
883
|
+
// classes). When present it *replaces* the Play CDN below — reliable + local,
|
|
884
|
+
// no network dependency in the headless render.
|
|
885
|
+
if (jitCss) parts.push(`<link rel="stylesheet" href="${jitCss}">`);
|
|
886
|
+
|
|
887
|
+
// 2. Tailwind runtime config override (must precede any Tailwind CDN <script>).
|
|
888
|
+
// Only configures the CDN, so skip it entirely when JIT CSS covers the classes.
|
|
889
|
+
if (framework.includes('tailwind') && truthy(branding.tailwind_extend_raw) && !jitCss) {
|
|
890
|
+
const extend = replaceAllLiteral(String(branding.tailwind_extend_raw), '</script>', '<\\/script>');
|
|
891
|
+
parts.push(`<script>window.tailwind=window.tailwind||{};window.tailwind.config={theme:{extend:${extend}}};</script>`);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 3. Framework CDN.
|
|
895
|
+
// Non-Tailwind: skip when compiled CSS is present (CDN would duplicate coverage).
|
|
896
|
+
// Tailwind: inject alongside the (purged) compiled CSS — UNLESS JIT CSS is present.
|
|
897
|
+
if (truthy(frameworkCdn) && (!truthy(compiledCssPath) || framework.includes('tailwind')) && !jitCss) {
|
|
898
|
+
if (String(frameworkCdn).endsWith('.css')) parts.push(`<link rel="stylesheet" href="${frameworkCdn}">`);
|
|
899
|
+
else parts.push(`<script src="${frameworkCdn}"></script>`); // Tailwind's CDN is a JS bundle
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// 4. Tailwind CDN fallback — fires when no framework_cdn is in branding.json.
|
|
903
|
+
// Skipped when JIT CSS is present (it already carries the mockup's classes).
|
|
904
|
+
if (isTailwindFamily && !truthy(frameworkCdn) && !jitCss) {
|
|
905
|
+
if (cssContent && !truthy(compiledCssPath)) parts.push(`<style>\n${cssContent}\n</style>`);
|
|
906
|
+
else parts.push(TAILWIND_CDN);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 5. Google Fonts. detect-project may record these either as a plain CSS URL
|
|
910
|
+
// string OR as a structured descriptor object ({"family","weights","source"} —
|
|
911
|
+
// e.g. a next/font/google import). Build a real fonts.googleapis.com URL from
|
|
912
|
+
// the object; stringifying it straight into href yields a broken link that
|
|
913
|
+
// 404s (leaving the mockup on a system-font fallback).
|
|
914
|
+
for (const item of (truthy(branding.google_fonts) ? branding.google_fonts : [])) {
|
|
915
|
+
const url = fontDescriptorToUrl(item);
|
|
916
|
+
if (url) parts.push(`<link rel="stylesheet" href="${url}">`);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 6. External stylesheets (tippy, highlight.js, etc.)
|
|
920
|
+
for (const url of (truthy(branding.external_stylesheets) ? branding.external_stylesheets : [])) {
|
|
921
|
+
parts.push(`<link rel="stylesheet" href="${url}">`);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 7. :root vars + design tokens + var overrides
|
|
925
|
+
const styleBlocks = [];
|
|
926
|
+
const rootLines = [];
|
|
927
|
+
if (truthy(branding.root_css)) rootLines.push(branding.root_css);
|
|
928
|
+
const cardFont = firstGoogleFontFamily(branding);
|
|
929
|
+
if (cardFont) rootLines.push(` --walkthrough-card-font: '${cardFont}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;`);
|
|
930
|
+
if (truthy(branding.default_colors)) {
|
|
931
|
+
for (const [k, v] of Object.entries(branding.default_colors)) rootLines.push(` --brand-${k.replaceAll('_', '-')}: ${v};`);
|
|
932
|
+
}
|
|
933
|
+
if (truthy(branding.css_var_overrides)) {
|
|
934
|
+
for (const [k, v] of Object.entries(branding.css_var_overrides)) rootLines.push(` ${k}: ${v};`);
|
|
935
|
+
}
|
|
936
|
+
if (rootLines.length) styleBlocks.push(':root {\n' + rootLines.join('\n') + '\n}');
|
|
937
|
+
if (truthy(branding.dark_css)) styleBlocks.push('.dark {\n' + branding.dark_css + '\n}');
|
|
938
|
+
|
|
939
|
+
if (styleBlocks.length) {
|
|
940
|
+
// Defensive escape — we're concatenating arbitrary CSS into a <style> tag
|
|
941
|
+
const cssPayload = replaceAllLiteral(styleBlocks.join('\n'), '</style>', '<\\/style>');
|
|
942
|
+
parts.push(`<style data-walkthrough-branding="1">\n${cssPayload}\n</style>`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 8. Generated bridge CSS (e.g. the desktop window-bg bridge) — last, so it
|
|
946
|
+
// wins source-order ties against the compiled stylesheet it derives from.
|
|
947
|
+
if (extraCss) {
|
|
948
|
+
parts.push(`<style data-rtfm-bridge="1">\n${replaceAllLiteral(extraCss, '</style>', '<\\/style>')}\n</style>`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return parts.length ? parts.join('\n ') : TAILWIND_CDN;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ─── SVG sprite inlining ─────────────────────────────────────────────────────
|
|
955
|
+
// <use href="icons.svg#gear"> references an external sprite file, which can't
|
|
956
|
+
// load from a self-contained file:// mockup (and would be blocked anyway).
|
|
957
|
+
// Inline the referenced <symbol>s into a hidden in-document sprite and rewrite
|
|
958
|
+
// the hrefs to fragment-only (#gear), which works everywhere.
|
|
959
|
+
|
|
960
|
+
const SPRITE_USE_RE = /(?:xlink:)?href\s*=\s*["']([^"'#]+\.svg)#([\w:.-]+)["']/gi;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Rewrite external sprite refs in <use> to in-document symbols.
|
|
964
|
+
* spriteCache maps basename(lower) → file text (shared across steps).
|
|
965
|
+
*/
|
|
966
|
+
function inlineSvgSprites(html, projectDir, spriteCache) {
|
|
967
|
+
const refs = [];
|
|
968
|
+
for (const match of html.matchAll(SPRITE_USE_RE)) {
|
|
969
|
+
// Only rewrite refs that appear inside <use …> tags
|
|
970
|
+
const tagStart = match.index > 0 ? html.lastIndexOf('<', match.index - 1) : -1;
|
|
971
|
+
if (tagStart === -1 || !/^<use\b/i.test(html.slice(tagStart, match.index))) continue;
|
|
972
|
+
refs.push([match[0], match[1], match[2]]);
|
|
973
|
+
}
|
|
974
|
+
if (!refs.length) return html;
|
|
975
|
+
|
|
976
|
+
const basenames = new Set(refs.map(([, file]) => path.posix.basename(file).toLowerCase()));
|
|
977
|
+
const missing = [...basenames].filter(base => !(base in spriteCache));
|
|
978
|
+
if (missing.length) {
|
|
979
|
+
const lookup = findProjectAssets(projectDir, missing);
|
|
980
|
+
for (const base of missing) spriteCache[base] = base in lookup ? readFileOrEmpty(lookup[base]) : '';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const symbols = new Map();
|
|
984
|
+
for (const [raw, file, frag] of refs) {
|
|
985
|
+
const sprite = spriteCache[path.posix.basename(file).toLowerCase()] ?? '';
|
|
986
|
+
if (!sprite || symbols.has(frag)) {
|
|
987
|
+
if (sprite) html = replaceAllLiteral(html, raw, `href="#${frag}"`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const sym = sprite.match(new RegExp(`<symbol\\b[^>]*\\bid\\s*=\\s*["']${escapeRegExp(frag)}["'][\\s\\S]*?</symbol>`, 'i'));
|
|
991
|
+
if (!sym) continue;
|
|
992
|
+
symbols.set(frag, sym[0]);
|
|
993
|
+
html = replaceAllLiteral(html, raw, `href="#${frag}"`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (symbols.size) {
|
|
997
|
+
const spriteSvg = '<svg xmlns="http://www.w3.org/2000/svg" style="display:none" '
|
|
998
|
+
+ 'aria-hidden="true" data-rtfm-sprite="1">' + [...symbols.values()].join('') + '</svg>';
|
|
999
|
+
html = html.replace(/(<body\b[^>]*>)/, match => match + spriteSvg);
|
|
1000
|
+
}
|
|
1001
|
+
return html;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Mark <html> so the renderers' glyph-substitute fallback CSS (which uses
|
|
1006
|
+
* !important) knows real icon fonts are embedded and stands down.
|
|
1007
|
+
*/
|
|
1008
|
+
function injectIconFontsMarker(html) {
|
|
1009
|
+
if (html.includes('data-rtfm-icon-fonts')) return html;
|
|
1010
|
+
return html.replace(/<html(\b[^>]*)?>/, (_, attrs) => `<html${attrs || ''} data-rtfm-icon-fonts="1">`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function injectCss(html, block) {
|
|
1014
|
+
if (html.includes('<!-- INJECT_CSS -->')) return replaceAllLiteral(html, '<!-- INJECT_CSS -->', block);
|
|
1015
|
+
if (html.includes('</head>')) return replaceAllLiteral(html, '</head>', `${block}\n</head>`);
|
|
1016
|
+
if (html.includes('<body')) return replaceFirstLiteral(html, '<body', `${block}\n<body`);
|
|
1017
|
+
return `<head>${block}</head>\n${html}`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function escapeText(text) {
|
|
1021
|
+
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function insertHead(html, headInserts) {
|
|
1025
|
+
if (html.includes('</head>')) return replaceFirstLiteral(html, '</head>', `${headInserts}\n</head>`);
|
|
1026
|
+
if (html.includes('<head')) return html.replace(/(<head[^>]*>)/, match => `${match}\n${headInserts}`);
|
|
1027
|
+
return `<head>${headInserts}</head>\n` + html;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Inject the polish CSS, cross-fade loader, and the zoomable stage wrapper
|
|
1032
|
+
* around the mockup content.
|
|
1033
|
+
*
|
|
1034
|
+
* No caption chrome is added — the step text lives in the narration and the
|
|
1035
|
+
* subtitles/transcript, so the mockup owns the full viewport frame.
|
|
1036
|
+
* `offsetBridge` is the per-step root-offset bridge <style> (see
|
|
1037
|
+
* fixedOffsetBridgeCss) — empty for apps with no root-level chrome offset.
|
|
1038
|
+
*/
|
|
1039
|
+
function injectStage(html, offsetBridge = '') {
|
|
1040
|
+
let headInserts = ` ${WALKTHROUGH_STYLE}\n ${WALKTHROUGH_LOADER}`;
|
|
1041
|
+
if (offsetBridge && !html.includes('data-rtfm-offset-bridge')) headInserts += `\n ${offsetBridge}`;
|
|
1042
|
+
html = insertHead(html, headInserts);
|
|
1043
|
+
|
|
1044
|
+
const bodyInserts = `\n${STAGE_OPEN}`;
|
|
1045
|
+
const bodyMatch = html.match(/<body[^>]*>/);
|
|
1046
|
+
if (bodyMatch) {
|
|
1047
|
+
const insertAt = bodyMatch.index + bodyMatch[0].length;
|
|
1048
|
+
html = html.slice(0, insertAt) + bodyInserts + html.slice(insertAt);
|
|
1049
|
+
html = html.includes('</body>') ? replaceFirstLiteral(html, '</body>', STAGE_CLOSE + '\n</body>') : html + STAGE_CLOSE;
|
|
1050
|
+
} else {
|
|
1051
|
+
html = bodyInserts + html + STAGE_CLOSE;
|
|
1052
|
+
}
|
|
1053
|
+
return html;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Add data-walkthrough-wipe-in="1" to the body tag of step_0.html so the
|
|
1058
|
+
* radial-wipe CSS in WALKTHROUGH_STYLE fires when the recorder navigates
|
|
1059
|
+
* from the intro card to step_0. No-op if the attribute is already present
|
|
1060
|
+
* or the body tag is missing.
|
|
1061
|
+
*/
|
|
1062
|
+
function injectStepZeroWipe(html) {
|
|
1063
|
+
const bodyMatch = html.match(/<body([^>]*)>/);
|
|
1064
|
+
if (!bodyMatch) return html;
|
|
1065
|
+
const bodyAttrs = bodyMatch[1];
|
|
1066
|
+
if (bodyAttrs.includes('data-walkthrough-wipe-in')) return html;
|
|
1067
|
+
const newOpen = `<body${bodyAttrs} data-walkthrough-wipe-in="1">`;
|
|
1068
|
+
return html.slice(0, bodyMatch.index) + newOpen + html.slice(bodyMatch.index + bodyMatch[0].length);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Inject just the polish CSS + cross-fade loader for title-card pages.
|
|
1073
|
+
* No stage wrapper — the card body controls its own layout.
|
|
1074
|
+
*/
|
|
1075
|
+
function injectCardLoader(html) {
|
|
1076
|
+
return insertHead(html, ` ${WALKTHROUGH_STYLE}\n ${WALKTHROUGH_LOADER}`);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function buildFilenameLookup(images) {
|
|
1080
|
+
const lookup = {};
|
|
1081
|
+
for (const [imagePath, uri] of Object.entries(images)) {
|
|
1082
|
+
const fname = path.posix.basename(imagePath);
|
|
1083
|
+
if (!(fname in lookup)) lookup[fname] = uri;
|
|
1084
|
+
}
|
|
1085
|
+
return lookup;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function makeReplaceImg(images, filenameLookup) {
|
|
1089
|
+
return (match, imgName) => {
|
|
1090
|
+
for (const key of [imgName, '/' + imgName, '/assets/' + imgName]) {
|
|
1091
|
+
if (Object.hasOwn(images, key)) return images[key];
|
|
1092
|
+
}
|
|
1093
|
+
return Object.hasOwn(filenameLookup, imgName) ? filenameLookup[imgName] : match;
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function stepIndexFromFilename(fname) {
|
|
1098
|
+
const match = fname.match(/^step_(\d+)\.html$/);
|
|
1099
|
+
return match ? Number(match[1]) : null;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Title-card filenames. The recorder treats these specially (no advance
|
|
1103
|
+
// element, fixed display duration). Auto-generated from article.json when
|
|
1104
|
+
// the walkthrough flag is set.
|
|
1105
|
+
const INTRO_FILENAME = 'step_intro.html';
|
|
1106
|
+
const OUTRO_FILENAME = 'step_outro.html';
|
|
1107
|
+
|
|
1108
|
+
// Filename fragments that suggest "this image is the project's wordmark/logo".
|
|
1109
|
+
// Used by findLogo() to pick a brand image for the title cards. Favicons are
|
|
1110
|
+
// excluded explicitly because they're typically too small (16-32px) to render
|
|
1111
|
+
// nicely at the 84px card-logo size.
|
|
1112
|
+
const LOGO_NAME_HINTS = ['logo', 'wordmark', 'brand-mark', 'brandmark', 'brand_logo'];
|
|
1113
|
+
|
|
1114
|
+
/** Pick a logo-like filename from the image manifest, if any. */
|
|
1115
|
+
function findLogo(filenameLookup) {
|
|
1116
|
+
const candidates = [];
|
|
1117
|
+
for (const name of Object.keys(filenameLookup)) {
|
|
1118
|
+
const lower = name.toLowerCase();
|
|
1119
|
+
if (lower.includes('favicon')) continue;
|
|
1120
|
+
if (LOGO_NAME_HINTS.some(hint => lower.includes(hint))) {
|
|
1121
|
+
// Prefer exact "logo.<ext>" matches over hyphenated variants.
|
|
1122
|
+
const dot = lower.lastIndexOf('.');
|
|
1123
|
+
const base = dot < 0 ? lower : lower.slice(0, dot);
|
|
1124
|
+
const rank = base === 'logo' ? 0 : (base.startsWith('logo') ? 1 : 2);
|
|
1125
|
+
candidates.push([rank, name]);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (!candidates.length) return null;
|
|
1129
|
+
candidates.sort((a, b) => (a[0] - b[0]) || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
|
|
1130
|
+
return candidates[0][1];
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Perceived luminance (0..1) of a #rgb/#rrggbb(aa) colour, or null. */
|
|
1134
|
+
function hexLuminance(hex) {
|
|
1135
|
+
let h = hex.replace(/^#+/, '');
|
|
1136
|
+
if (h.length === 3) h = [...h].map(c => c + c).join('');
|
|
1137
|
+
if (h.length !== 6 && h.length !== 8) return null;
|
|
1138
|
+
if (!/^[0-9a-fA-F]{6}/.test(h)) return null;
|
|
1139
|
+
const [r, g, b] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
|
|
1140
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ─── Raster measurement (title cards) ────────────────────────────────────────
|
|
1144
|
+
// Title-card theming needs pixel data from logos and card backgrounds. The
|
|
1145
|
+
// renderer's own Chromium decodes every format a mockup can use, so one
|
|
1146
|
+
// shared headless page does the decoding; the browser starts only when a
|
|
1147
|
+
// walkthrough actually has a raster to measure.
|
|
1148
|
+
|
|
1149
|
+
let rasterPage = null;
|
|
1150
|
+
let rasterBrowser = null;
|
|
1151
|
+
|
|
1152
|
+
async function rasterPixels(dataUri, crop = null) {
|
|
1153
|
+
if (!rasterPage) {
|
|
1154
|
+
let renderer;
|
|
1155
|
+
try { renderer = require('./render_mockup.js'); }
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
log(` raster measurement unavailable: ${error.message}`);
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
rasterBrowser = await renderer.puppeteer.launch(renderer.launchOptions());
|
|
1161
|
+
rasterPage = await rasterBrowser.newPage();
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
return await rasterPage.evaluate(async (src, box) => {
|
|
1165
|
+
const img = new Image();
|
|
1166
|
+
img.src = src;
|
|
1167
|
+
await img.decode();
|
|
1168
|
+
let [sx, sy, sw, sh] = [0, 0, img.naturalWidth, img.naturalHeight];
|
|
1169
|
+
if (!sw || !sh) return null;
|
|
1170
|
+
if (box) {
|
|
1171
|
+
sx = Math.trunc(sw * box[0]); sy = Math.trunc(sh * box[1]);
|
|
1172
|
+
const ex = Math.trunc(img.naturalWidth * box[2]); const ey = Math.trunc(img.naturalHeight * box[3]);
|
|
1173
|
+
sw = ex - sx; sh = ey - sy;
|
|
1174
|
+
}
|
|
1175
|
+
// Fit within 64x64 preserving aspect, never enlarging.
|
|
1176
|
+
const scale = Math.min(1, 64 / sw, 64 / sh);
|
|
1177
|
+
const w = Math.max(1, Math.round(sw * scale));
|
|
1178
|
+
const h = Math.max(1, Math.round(sh * scale));
|
|
1179
|
+
const canvas = document.createElement('canvas');
|
|
1180
|
+
canvas.width = w; canvas.height = h;
|
|
1181
|
+
const ctx = canvas.getContext('2d');
|
|
1182
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
|
|
1183
|
+
return Array.from(ctx.getImageData(0, 0, w, h).data);
|
|
1184
|
+
}, dataUri, crop);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function closeRaster() {
|
|
1191
|
+
if (rasterBrowser) await rasterBrowser.close().catch(() => {});
|
|
1192
|
+
rasterBrowser = null;
|
|
1193
|
+
rasterPage = null;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Best-effort ink measurement for a logo data URI. Returns
|
|
1198
|
+
* [mean ink luminance 0..1, hasTransparency] or null when undecidable
|
|
1199
|
+
* (bad data, unsupported format, or no renderer for rasters).
|
|
1200
|
+
*
|
|
1201
|
+
* SVG: average the luminance of explicit fill/stroke colours; treated as
|
|
1202
|
+
* transparent (SVG marks almost never paint a full background).
|
|
1203
|
+
* Raster: alpha-weighted mean luminance of visible pixels; for fully opaque
|
|
1204
|
+
* images (e.g. JPEG) near-white pixels are treated as background and
|
|
1205
|
+
* excluded so a dark mark on white reads as dark.
|
|
1206
|
+
*/
|
|
1207
|
+
async function logoInkStats(dataUri) {
|
|
1208
|
+
if (typeof dataUri !== 'string' || !dataUri.includes(',')) return null;
|
|
1209
|
+
const comma = dataUri.indexOf(',');
|
|
1210
|
+
const header = dataUri.slice(0, comma);
|
|
1211
|
+
const raw = Buffer.from(dataUri.slice(comma + 1), 'base64');
|
|
1212
|
+
|
|
1213
|
+
if (header.includes('svg')) {
|
|
1214
|
+
const svg = raw.toString('utf8');
|
|
1215
|
+
const lums = [...svg.matchAll(/(?:fill|stroke|stop-color)\s*[:=]\s*["']?(#[0-9a-fA-F]{3,8})/g)]
|
|
1216
|
+
.map(match => hexLuminance(match[1])).filter(lum => lum !== null);
|
|
1217
|
+
if (!lums.length) return null;
|
|
1218
|
+
return [lums.reduce((a, b) => a + b, 0) / lums.length, true];
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const data = await rasterPixels(dataUri);
|
|
1222
|
+
if (!data) return null;
|
|
1223
|
+
let hasAlpha = false;
|
|
1224
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] < 250) { hasAlpha = true; break; }
|
|
1225
|
+
const samples = [];
|
|
1226
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
1227
|
+
const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255.0;
|
|
1228
|
+
if (hasAlpha) { if (data[i + 3] >= 64) samples.push(lum); }
|
|
1229
|
+
else if (lum <= 0.95) samples.push(lum);
|
|
1230
|
+
}
|
|
1231
|
+
if (samples.length < 10) return null;
|
|
1232
|
+
return [samples.reduce((a, b) => a + b, 0) / samples.length, hasAlpha];
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Best-effort: is the logo's ink predominantly dark? true -> the logo
|
|
1237
|
+
* needs a light card; false -> a dark card. null when undecidable.
|
|
1238
|
+
*/
|
|
1239
|
+
async function logoIsDark(dataUri) {
|
|
1240
|
+
const stats = await logoInkStats(dataUri);
|
|
1241
|
+
return stats === null ? null : stats[0] < 0.55;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Mean perceived luminance (0..1) of the central region of a card
|
|
1246
|
+
* background image — the area the logo and title sit over (the gradient's
|
|
1247
|
+
* corner glows are deliberately cropped out). null when unreadable.
|
|
1248
|
+
*/
|
|
1249
|
+
async function cardBgLuminance(file) {
|
|
1250
|
+
let raw;
|
|
1251
|
+
try { raw = fs.readFileSync(file); } catch { return null; }
|
|
1252
|
+
const mime = CARD_LOGO_MIMES[path.extname(file).toLowerCase()] ?? 'image/png';
|
|
1253
|
+
const data = await rasterPixels(`data:${mime};base64,${raw.toString('base64')}`, [0.20, 0.22, 0.80, 0.78]);
|
|
1254
|
+
if (!data || !data.length) return null;
|
|
1255
|
+
let sum = 0;
|
|
1256
|
+
for (let i = 0; i < data.length; i += 4) sum += (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255.0;
|
|
1257
|
+
return sum / (data.length / 4);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Light card for dark logos, dark card for light logos. Defaults to
|
|
1262
|
+
* light when there is no logo or its ink colour can't be determined.
|
|
1263
|
+
*/
|
|
1264
|
+
async function pickCardTheme(logoFilename, filenameLookup) {
|
|
1265
|
+
if (!logoFilename) return 'light';
|
|
1266
|
+
const darkLogo = await logoIsDark(filenameLookup[logoFilename]);
|
|
1267
|
+
return darkLogo === false ? 'dark' : 'light';
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Produce raw HTML for an intro or outro title card. Contains the same
|
|
1272
|
+
* {{img:...}} and <!-- INJECT_CSS --> placeholders as a normal step file,
|
|
1273
|
+
* so the existing CSS + image substitution still applies.
|
|
1274
|
+
*
|
|
1275
|
+
* backgroundImage (card_bg.png) sits on a dedicated sibling div so it can be
|
|
1276
|
+
* scale-transformed for the Ken Burns effect without affecting card content.
|
|
1277
|
+
* logoDataUri (the account's uploaded branding logo) is embedded directly and
|
|
1278
|
+
* takes precedence over logoFilename (the repo-detected logo resolved from
|
|
1279
|
+
* the image manifest). logoPlate ("light"/"dark") adds a translucent contrast
|
|
1280
|
+
* plate behind the logo.
|
|
1281
|
+
*/
|
|
1282
|
+
function buildCardHtml({ kind, title, tagline, logoFilename, backgroundImage = null, theme = 'light', logoDataUri = null, logoPlate = null }) {
|
|
1283
|
+
const safeTitle = escapeText(title || '');
|
|
1284
|
+
const safeTagline = escapeText(tagline || '');
|
|
1285
|
+
const plateClass = logoPlate ? ` walkthrough-card-logo--plate-${logoPlate}` : '';
|
|
1286
|
+
let logoHtml = '';
|
|
1287
|
+
if (logoDataUri) logoHtml = `<img class="walkthrough-card-logo${plateClass}" src="${logoDataUri}" alt="">`;
|
|
1288
|
+
else if (logoFilename) logoHtml = `<img class="walkthrough-card-logo${plateClass}" src="{{img:${logoFilename}}}" alt="">`;
|
|
1289
|
+
const taglineHtml = safeTagline ? `<p class="walkthrough-card-tagline">${safeTagline}</p>` : '';
|
|
1290
|
+
// Body keeps its flat brand-colour fallback (set in WALKTHROUGH_STYLE) so a
|
|
1291
|
+
// missing image degrades to a clean coloured frame rather than a white flash.
|
|
1292
|
+
const bgHtml = backgroundImage
|
|
1293
|
+
? `<div class="walkthrough-card-bg" style="background-image:url('${backgroundImage.replaceAll("'", '%27')}');"></div>`
|
|
1294
|
+
: '';
|
|
1295
|
+
return `<!doctype html>
|
|
1296
|
+
<html lang="en">
|
|
1297
|
+
<head>
|
|
1298
|
+
<meta charset="utf-8">
|
|
1299
|
+
<title>${safeTitle}</title>
|
|
1300
|
+
<!-- INJECT_CSS -->
|
|
1301
|
+
</head>
|
|
1302
|
+
<body data-walkthrough-card="${kind}" data-card-theme="${theme}" data-viewport="wide">
|
|
1303
|
+
${bgHtml}
|
|
1304
|
+
<div class="walkthrough-card">
|
|
1305
|
+
${logoHtml}
|
|
1306
|
+
<h1 class="walkthrough-card-title"><span class="walkthrough-card-title-text">${safeTitle}</span></h1>
|
|
1307
|
+
${taglineHtml}
|
|
1308
|
+
</div>
|
|
1309
|
+
</body>
|
|
1310
|
+
</html>
|
|
1311
|
+
`;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Filename of the card background (when present). It lives in the
|
|
1315
|
+
// project-level .rtfm/ cache (legacy: .rtfm-branding/); this module copies it
|
|
1316
|
+
// into each walkthrough's output dir at process time so the title-card HTML
|
|
1317
|
+
// can reference it with a relative path (the same pattern as branding.css).
|
|
1318
|
+
const CARD_BG_FILENAME = 'card_bg.png';
|
|
1319
|
+
|
|
1320
|
+
/** Resolve a cached card background PNG next to branding.json, or null. */
|
|
1321
|
+
function findCardBg(brandingJsonPath) {
|
|
1322
|
+
if (!brandingJsonPath) return null;
|
|
1323
|
+
const candidate = path.join(path.dirname(brandingJsonPath) || '.', CARD_BG_FILENAME);
|
|
1324
|
+
return isFile(candidate) ? candidate : null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// The account's branding logo, seeded into .rtfm/ as card_logo.<ext>. When
|
|
1328
|
+
// present it overrides the repo-detected logo on the title cards.
|
|
1329
|
+
const CARD_LOGO_MIMES = {
|
|
1330
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1331
|
+
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.gif': 'image/gif',
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
/** Resolve the branding logo (card_logo.<ext>) next to branding.json, or null. */
|
|
1335
|
+
function findCardLogo(brandingJsonPath) {
|
|
1336
|
+
if (!brandingJsonPath) return null;
|
|
1337
|
+
const dir = path.dirname(brandingJsonPath) || '.';
|
|
1338
|
+
for (const ext of Object.keys(CARD_LOGO_MIMES)) {
|
|
1339
|
+
const candidate = path.join(dir, `card_logo${ext}`);
|
|
1340
|
+
if (isFile(candidate)) return candidate;
|
|
1341
|
+
}
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Read a logo file into a base64 data URI for direct embedding, or null. */
|
|
1346
|
+
function cardLogoDataUri(file) {
|
|
1347
|
+
if (!file || !isFile(file)) return null;
|
|
1348
|
+
const mime = CARD_LOGO_MIMES[path.extname(file).toLowerCase()] ?? 'image/png';
|
|
1349
|
+
try { return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`; }
|
|
1350
|
+
catch { return null; }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function sameFile(a, b) {
|
|
1354
|
+
try {
|
|
1355
|
+
const [sa, sb] = [fs.statSync(a), fs.statSync(b)];
|
|
1356
|
+
return sa.dev === sb.dev && sa.ino === sb.ino;
|
|
1357
|
+
} catch { return false; }
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Write step_intro.html + step_outro.html in htmlDir using article-level
|
|
1362
|
+
* metadata from the merged walkthrough data. Skips emission when article
|
|
1363
|
+
* info is empty (e.g. article.json was missing).
|
|
1364
|
+
*
|
|
1365
|
+
* When cardBgSource is a path to an existing PNG, the file is copied into
|
|
1366
|
+
* htmlDir as card_bg.png and referenced as the card body's background-image.
|
|
1367
|
+
*/
|
|
1368
|
+
async function writeTitleCards(htmlDir, walkthroughData, filenameLookup, { cardBgSource = null, cardLogoSource = null } = {}) {
|
|
1369
|
+
if (!walkthroughData) return [];
|
|
1370
|
+
const article = walkthroughData.article || {};
|
|
1371
|
+
const title = String(article.title || '').trim();
|
|
1372
|
+
if (!title) return [];
|
|
1373
|
+
const introTagline = String(article.introduction || '').trim();
|
|
1374
|
+
const outroTagline = String(article.summary || '').trim();
|
|
1375
|
+
// The account's uploaded branding logo (if seeded) wins over the
|
|
1376
|
+
// repo-detected one. Theme follows whichever logo is used.
|
|
1377
|
+
const logoUri = cardLogoDataUri(cardLogoSource);
|
|
1378
|
+
const logo = logoUri ? null : findLogo(filenameLookup);
|
|
1379
|
+
let theme;
|
|
1380
|
+
let logoLabel;
|
|
1381
|
+
if (logoUri) {
|
|
1382
|
+
theme = (await logoIsDark(logoUri)) === false ? 'dark' : 'light';
|
|
1383
|
+
logoLabel = 'branding';
|
|
1384
|
+
} else {
|
|
1385
|
+
theme = await pickCardTheme(logo, filenameLookup);
|
|
1386
|
+
logoLabel = logo || 'none';
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
let backgroundImage = null;
|
|
1390
|
+
let logoPlate = null;
|
|
1391
|
+
if (cardBgSource && isFile(cardBgSource)) {
|
|
1392
|
+
const dest = path.join(htmlDir, CARD_BG_FILENAME);
|
|
1393
|
+
// Skip the copy if dest is already the same file (e.g. running in place).
|
|
1394
|
+
if (!sameFile(cardBgSource, dest)) fs.copyFileSync(cardBgSource, dest);
|
|
1395
|
+
backgroundImage = CARD_BG_FILENAME;
|
|
1396
|
+
// The image covers the theme's flat background, so the logo-derived
|
|
1397
|
+
// theme no longer guarantees contrast. Re-pick the text theme from
|
|
1398
|
+
// the image's own luminance, and plate the logo when its ink sits
|
|
1399
|
+
// near the background's (green logo on a green gradient). Opaque
|
|
1400
|
+
// rasters skip the plate — they carry their own background box.
|
|
1401
|
+
const bgLum = await cardBgLuminance(dest);
|
|
1402
|
+
if (bgLum !== null) {
|
|
1403
|
+
theme = bgLum < 0.55 ? 'dark' : 'light';
|
|
1404
|
+
const ink = await logoInkStats(logoUri || (logo ? filenameLookup[logo] : null));
|
|
1405
|
+
if (ink !== null) {
|
|
1406
|
+
const [inkLum, transparent] = ink;
|
|
1407
|
+
if (transparent && Math.abs(inkLum - bgLum) < 0.35) logoPlate = inkLum < 0.55 ? 'light' : 'dark';
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const plateNote = logoPlate ? `, ${logoPlate} logo plate` : '';
|
|
1412
|
+
const bgNote = backgroundImage ? `, bg image${plateNote}` : '';
|
|
1413
|
+
log(` title cards: ${theme} theme (logo: ${logoLabel}${bgNote})`);
|
|
1414
|
+
|
|
1415
|
+
const shared = { title, logoFilename: logo, backgroundImage, theme, logoDataUri: logoUri, logoPlate };
|
|
1416
|
+
fs.writeFileSync(path.join(htmlDir, INTRO_FILENAME), buildCardHtml({ kind: 'intro', tagline: introTagline, ...shared }));
|
|
1417
|
+
fs.writeFileSync(path.join(htmlDir, OUTRO_FILENAME), buildCardHtml({ kind: 'outro', tagline: outroTagline, ...shared }));
|
|
1418
|
+
return [INTRO_FILENAME, OUTRO_FILENAME];
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function processDir(htmlDir, cssFile, imagesJson, actionsJson, brandingJson = null, generatedImagesJson = null, refreshJit = false) {
|
|
1422
|
+
const cssContent = loadCss(cssFile);
|
|
1423
|
+
const images = loadImages(imagesJson);
|
|
1424
|
+
const generatedImages = loadGeneratedImages(generatedImagesJson);
|
|
1425
|
+
const walkthroughData = loadWalkthroughSteps(actionsJson);
|
|
1426
|
+
const branding = loadBranding(brandingJson);
|
|
1427
|
+
|
|
1428
|
+
if (!isDir(htmlDir)) {
|
|
1429
|
+
log(`Error: ${htmlDir} is not a directory`);
|
|
1430
|
+
return 1;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const projectDir = process.cwd();
|
|
1434
|
+
const cacheDir = brandingJson ? path.dirname(path.resolve(brandingJson)) : htmlDir;
|
|
1435
|
+
const compiledCssPath = branding && truthy(branding.compiled_css_path) ? branding.compiled_css_path : null;
|
|
1436
|
+
|
|
1437
|
+
// Inline relative url() assets in the compiled CSS so background-image
|
|
1438
|
+
// classes render their real artwork instead of a blank region.
|
|
1439
|
+
if (compiledCssPath) {
|
|
1440
|
+
const outCss = path.join(htmlDir, compiledCssPath);
|
|
1441
|
+
if (isFile(outCss)) {
|
|
1442
|
+
const inlined = inlineCssAssetUrls(outCss, projectDir);
|
|
1443
|
+
if (inlined) {
|
|
1444
|
+
log(` inlined ${inlined} CSS url() asset(s) into ${compiledCssPath}`);
|
|
1445
|
+
// Persist back to the project cache so future runs reuse the inlined version.
|
|
1446
|
+
const cacheCss = path.join(cacheDir, compiledCssPath);
|
|
1447
|
+
if (isFile(cacheCss) && path.resolve(cacheCss) !== path.resolve(outCss)) {
|
|
1448
|
+
try { fs.writeFileSync(cacheCss, fs.readFileSync(outCss, 'utf8')); }
|
|
1449
|
+
catch (error) { log(`WARNING: could not persist inlined CSS to cache: ${error.message}`); }
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Render-time JIT (deterministic — not left to the model's bash block): when the
|
|
1456
|
+
// project has a cached Tailwind `css_build` recipe, locally compile the mockups'
|
|
1457
|
+
// OWN classes into <htmlDir>/mockup.css via jit_mockup_css.js, so arbitrary /
|
|
1458
|
+
// responsive / plugin classes render without the flaky Play CDN. Runs from here
|
|
1459
|
+
// so it always fires. Skips cleanly (no mockup.css) when there's no recipe.
|
|
1460
|
+
const mockupCssFile = path.join(htmlDir, 'mockup.css');
|
|
1461
|
+
let recipe = null;
|
|
1462
|
+
const projectMap = path.join(cacheDir, 'project_map.json');
|
|
1463
|
+
try { recipe = readJson(projectMap).css_build ?? null; } catch { /* no recipe */ }
|
|
1464
|
+
const jitPath = path.join(__dirname, 'jit_mockup_css.js');
|
|
1465
|
+
// Progressive article rendering calls the injector after each newly authored
|
|
1466
|
+
// mockup. Rebuild the JIT bundle so classes introduced by that step are
|
|
1467
|
+
// available immediately; the normal final/batch path keeps its cached bundle.
|
|
1468
|
+
if (refreshJit && isFile(mockupCssFile)) {
|
|
1469
|
+
try { fs.rmSync(mockupCssFile); }
|
|
1470
|
+
catch (error) { log(`WARNING: could not refresh ${mockupCssFile}: ${error.message}`); }
|
|
1471
|
+
}
|
|
1472
|
+
if (truthy(recipe) && !isFile(mockupCssFile) && isFile(jitPath)) {
|
|
1473
|
+
const jitLog = path.join(htmlDir, 'jit.log');
|
|
1474
|
+
const r = spawnSync(process.execPath, [jitPath, htmlDir, projectDir, projectMap], { timeout: 180000, encoding: 'utf8' });
|
|
1475
|
+
if (r.error) {
|
|
1476
|
+
// Never let the JIT break injection.
|
|
1477
|
+
fs.writeFileSync(jitLog, `jit invocation EXCEPTION: ${r.error.message}\ncwd=${process.cwd()}\nproject_dir=${projectDir}\n`);
|
|
1478
|
+
log(` jit invocation error (keeping CDN/compiled CSS): ${r.error.message}`);
|
|
1479
|
+
} else {
|
|
1480
|
+
fs.writeFileSync(jitLog, `cwd=${process.cwd()}\nproject_dir=${projectDir}\njit_path=${jitPath}\n`
|
|
1481
|
+
+ `rc=${r.status}\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}\n`);
|
|
1482
|
+
if (r.stderr) log(r.stderr);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const jitCss = isFile(mockupCssFile) ? 'mockup.css' : null;
|
|
1486
|
+
if (jitCss) log(' using local JIT mockup.css (dropping Tailwind CDN)');
|
|
1487
|
+
// Treat a project with a Tailwind recipe as Tailwind-family for the CDN fallback,
|
|
1488
|
+
// so a framework like "phoenix" still gets coverage if the JIT couldn't run.
|
|
1489
|
+
const twHint = Boolean(isObject(recipe) && truthy(recipe.tw_version));
|
|
1490
|
+
|
|
1491
|
+
const htmlFiles = fs.readdirSync(htmlDir).sort();
|
|
1492
|
+
|
|
1493
|
+
// Desktop mockups: bridge body-scoped app backgrounds into the framed window
|
|
1494
|
+
// interior. Gated on the mockups actually using the desktop frame, so web /
|
|
1495
|
+
// terminal / mobile output is untouched.
|
|
1496
|
+
let bridgeCss = '';
|
|
1497
|
+
const usesDesktopFrame = htmlFiles.some(f => f.endsWith('.html') && readFileOrEmpty(path.join(htmlDir, f)).includes('desktop-window-content'));
|
|
1498
|
+
if (usesDesktopFrame) {
|
|
1499
|
+
const bridgeSources = [cssContent || ''];
|
|
1500
|
+
if (compiledCssPath) bridgeSources.push(readFileOrEmpty(path.join(htmlDir, compiledCssPath)));
|
|
1501
|
+
bridgeCss = desktopWindowBgBridge(bridgeSources.join('\n'));
|
|
1502
|
+
if (bridgeCss) {
|
|
1503
|
+
log(` desktop window-bg bridge: ${bridgeCss.split('{').length - 1} body-background rule(s) re-emitted onto .desktop-window-content`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// CSS text the walkthrough root-offset bridge scans: css_content plus the
|
|
1508
|
+
// compiled CSS file sitting next to the mockups (the walkthrough SKILL passes
|
|
1509
|
+
// css_file="" and the compiled CSS rides --branding's compiled_css_path).
|
|
1510
|
+
let offsetBridgeCss = cssContent || '';
|
|
1511
|
+
if (compiledCssPath) offsetBridgeCss += '\n' + readFileOrEmpty(path.join(htmlDir, compiledCssPath));
|
|
1512
|
+
|
|
1513
|
+
// Sentinel so a re-run (STEP 4 lint-retry) can detect already-injected HTML
|
|
1514
|
+
// and skip re-injecting — otherwise the whole block stacks a 2nd/3rd time.
|
|
1515
|
+
// Prepended to the block itself so it's ALWAYS present regardless of which
|
|
1516
|
+
// optional sub-blocks buildCssHeadBlock emits.
|
|
1517
|
+
let block = INJECTED_SENTINEL + '\n' + buildCssHeadBlock(cssContent, branding, { jitCss, twHint, extraCss: bridgeCss });
|
|
1518
|
+
|
|
1519
|
+
// Embed real icon fonts when the mockups use icon classes (fa-*/bi-*).
|
|
1520
|
+
const iconCss = await ensureIconFonts(htmlDir, cacheDir);
|
|
1521
|
+
if (iconCss) block += `\n <link rel="stylesheet" href="${iconCss}">`;
|
|
1522
|
+
const filenameLookup = buildFilenameLookup(images);
|
|
1523
|
+
const replaceImg = makeReplaceImg(images, filenameLookup);
|
|
1524
|
+
|
|
1525
|
+
const walkthroughSteps = walkthroughData ? walkthroughData.steps : null;
|
|
1526
|
+
let cardsWritten = [];
|
|
1527
|
+
// Account branding drives the cards: card_bg.png (a brand-colour gradient
|
|
1528
|
+
// rendered from the account's colours, or a user upload) as the background,
|
|
1529
|
+
// and card_logo.<ext> (the account's uploaded logo) overlaid. Both are
|
|
1530
|
+
// resolved next to branding.json; when absent the cards fall back to the
|
|
1531
|
+
// CSS gradient theme + repo logo.
|
|
1532
|
+
const cardBgSource = walkthroughData ? findCardBg(brandingJson) : null;
|
|
1533
|
+
const cardLogoSource = walkthroughData ? findCardLogo(brandingJson) : null;
|
|
1534
|
+
try {
|
|
1535
|
+
if (walkthroughData) cardsWritten = await writeTitleCards(htmlDir, walkthroughData, filenameLookup, { cardBgSource, cardLogoSource });
|
|
1536
|
+
} finally {
|
|
1537
|
+
await closeRaster();
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
let count = 0;
|
|
1541
|
+
const spriteCache = {};
|
|
1542
|
+
for (const fname of fs.readdirSync(htmlDir).sort()) {
|
|
1543
|
+
if (!(fname.startsWith('step_') || fname.startsWith('block_')) || !fname.endsWith('.html')) continue;
|
|
1544
|
+
const fpath = path.join(htmlDir, fname);
|
|
1545
|
+
let html = fs.readFileSync(fpath, 'utf8');
|
|
1546
|
+
|
|
1547
|
+
// Preserve the pre-injection source for the screen library. Numbered
|
|
1548
|
+
// step mockups only (not the auto-generated intro/outro cards), and
|
|
1549
|
+
// only when the CSS placeholder is still present -- STEP 4 re-runs
|
|
1550
|
+
// (lint retry) on already-injected HTML must not clobber the backup.
|
|
1551
|
+
// ".html.pre" deliberately does not end in ".html" so this loop's
|
|
1552
|
+
// filter never re-processes it.
|
|
1553
|
+
if (/^(?:step_\d+|block_[a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.test(fname) && html.includes('<!-- INJECT_CSS -->')) {
|
|
1554
|
+
fs.writeFileSync(fpath + '.pre', html);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Idempotent CSS injection. STEP 4's lint-retry re-runs processDir on
|
|
1558
|
+
// already-injected HTML (the model edits the injected step file to fix a
|
|
1559
|
+
// lint failure, then re-injects). The <!-- INJECT_CSS --> marker is gone
|
|
1560
|
+
// by then, so a naive injectCss() falls through to the </head> branch and
|
|
1561
|
+
// stamps a SECOND (then THIRD) copy of the whole block mid-document. If
|
|
1562
|
+
// our sentinel is already present, skip re-injecting.
|
|
1563
|
+
if (!html.includes(INJECTED_SENTINEL)) html = injectCss(html, block);
|
|
1564
|
+
if (iconCss) html = injectIconFontsMarker(html);
|
|
1565
|
+
html = inlineSvgSprites(html, projectDir, spriteCache);
|
|
1566
|
+
html = html.replace(/\{\{img:([^}]+)\}\}/g, replaceImg);
|
|
1567
|
+
html = html.replace(/\{\{generated:([a-z0-9]+(?:-[a-z0-9]+)*)\}\}/g,
|
|
1568
|
+
(match, id) => (Object.hasOwn(generatedImages, id) ? generatedImages[id] : match));
|
|
1569
|
+
|
|
1570
|
+
const isCard = fname === INTRO_FILENAME || fname === OUTRO_FILENAME;
|
|
1571
|
+
if (walkthroughSteps !== null) {
|
|
1572
|
+
if (isCard) {
|
|
1573
|
+
html = injectCardLoader(html);
|
|
1574
|
+
} else {
|
|
1575
|
+
const idx = stepIndexFromFilename(fname);
|
|
1576
|
+
if (idx !== null && idx < walkthroughSteps.length) {
|
|
1577
|
+
html = injectStage(html, fixedOffsetBridgeCss(html, offsetBridgeCss));
|
|
1578
|
+
// Only step_0 gets the radial wipe — used as the cinematic
|
|
1579
|
+
// reveal out of the intro card. Step-to-step transitions
|
|
1580
|
+
// keep the existing brand-wash cross-fade.
|
|
1581
|
+
if (idx === 0) html = injectStepZeroWipe(html);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
fs.writeFileSync(fpath, html);
|
|
1587
|
+
count += 1;
|
|
1588
|
+
log(` injected: ${fname}`);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
let styleLabel;
|
|
1592
|
+
if (compiledCssPath) styleLabel = `compiled CSS (${branding.framework || 'detected'})`;
|
|
1593
|
+
else if (branding && truthy(branding.framework_cdn)) styleLabel = `${branding.framework ?? 'None'} CDN`;
|
|
1594
|
+
else if (cssContent) styleLabel = 'compiled CSS (legacy --css)';
|
|
1595
|
+
else styleLabel = 'Tailwind CDN';
|
|
1596
|
+
const extras = [];
|
|
1597
|
+
if (walkthroughSteps !== null) extras.push(`${walkthroughSteps.length} step stage(s)`);
|
|
1598
|
+
if (cardsWritten.length) extras.push(`${cardsWritten.length} title card(s)${cardBgSource ? ' with AI background' : ''}`);
|
|
1599
|
+
if (branding) {
|
|
1600
|
+
if (truthy(branding.root_css)) extras.push('root vars');
|
|
1601
|
+
if (truthy(branding.default_colors)) extras.push(`${Object.keys(branding.default_colors).length} brand colours`);
|
|
1602
|
+
if (truthy(branding.google_fonts)) extras.push(`${branding.google_fonts.length} font(s)`);
|
|
1603
|
+
}
|
|
1604
|
+
if (Object.keys(generatedImages).length) extras.push(`${Object.keys(generatedImages).length} generated image(s)`);
|
|
1605
|
+
const extrasStr = extras.length ? `, ${extras.join(', ')}` : '';
|
|
1606
|
+
log(`${count} file(s) processed (${styleLabel}, ${Object.keys(images).length} image entries${extrasStr})`);
|
|
1607
|
+
return 0;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Stamp the running skills version + this file's content hash to stderr, so a
|
|
1612
|
+
* debug bundle reveals exactly which code ran. The hash is authoritative (it
|
|
1613
|
+
* works even when the VERSION file isn't in a vendored tree).
|
|
1614
|
+
*/
|
|
1615
|
+
function logSkillsVersion() {
|
|
1616
|
+
const here = path.dirname(fs.realpathSync(__filename));
|
|
1617
|
+
let version = 'unknown';
|
|
1618
|
+
for (const candidate of [path.join(here, '..', '..', 'VERSION'), path.join(here, '..', 'VERSION')]) {
|
|
1619
|
+
try {
|
|
1620
|
+
const v = fs.readFileSync(candidate, 'utf8').trim();
|
|
1621
|
+
if (v) { version = v; break; }
|
|
1622
|
+
} catch { /* try the next */ }
|
|
1623
|
+
}
|
|
1624
|
+
let sha = '?';
|
|
1625
|
+
try { sha = crypto.createHash('sha256').update(fs.readFileSync(fs.realpathSync(__filename))).digest('hex').slice(0, 8); }
|
|
1626
|
+
catch { /* unreadable */ }
|
|
1627
|
+
log(`rtfm-skills inject_assets.js — v${version} (sha ${sha})`);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const USAGE = 'usage: inject_assets.js <html_dir> <css_file_or_empty> <images_json> [--walkthrough <actions_json>] '
|
|
1631
|
+
+ '[--branding <branding_json>] [--generated-images <generated_images_json>] [--refresh-jit]';
|
|
1632
|
+
|
|
1633
|
+
function parseArgs(argv) {
|
|
1634
|
+
const valued = { '--walkthrough': 'walkthrough', '--branding': 'branding', '--generated-images': 'generatedImages' };
|
|
1635
|
+
const options = { walkthrough: null, branding: null, generatedImages: null, refreshJit: false };
|
|
1636
|
+
const positional = [];
|
|
1637
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1638
|
+
const arg = argv[i];
|
|
1639
|
+
const eq = arg.indexOf('=');
|
|
1640
|
+
const name = arg.startsWith('--') && eq > 0 ? arg.slice(0, eq) : arg;
|
|
1641
|
+
if (name === '--refresh-jit' && eq < 0) options.refreshJit = true;
|
|
1642
|
+
else if (Object.hasOwn(valued, name)) {
|
|
1643
|
+
const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
|
|
1644
|
+
if (value === undefined) throw new Error(`argument ${name}: expected one argument`);
|
|
1645
|
+
options[valued[name]] = value;
|
|
1646
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
1647
|
+
process.stdout.write(USAGE + '\n');
|
|
1648
|
+
process.exit(0);
|
|
1649
|
+
} else if (arg.startsWith('--') && arg.length > 2) {
|
|
1650
|
+
throw new Error(`unrecognized arguments: ${arg}`);
|
|
1651
|
+
} else {
|
|
1652
|
+
positional.push(arg);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (positional.length !== 3) throw new Error(positional.length < 3 ? 'the following arguments are required: html_dir, css_file, images_json' : `unrecognized arguments: ${positional.slice(3).join(' ')}`);
|
|
1656
|
+
return { positional, options };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function main(argv) {
|
|
1660
|
+
logSkillsVersion();
|
|
1661
|
+
let parsed;
|
|
1662
|
+
try { parsed = parseArgs(argv); }
|
|
1663
|
+
catch (error) {
|
|
1664
|
+
log(`${USAGE}\ninject_assets.js: error: ${error.message}`);
|
|
1665
|
+
return 2;
|
|
1666
|
+
}
|
|
1667
|
+
const [htmlDir, cssFile, imagesJson] = parsed.positional;
|
|
1668
|
+
const { walkthrough, branding, generatedImages, refreshJit } = parsed.options;
|
|
1669
|
+
return processDir(htmlDir, cssFile, imagesJson, walkthrough, branding, generatedImages, refreshJit);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (require.main === module) {
|
|
1673
|
+
main(process.argv.slice(2)).then(code => { process.exitCode = code; }, error => {
|
|
1674
|
+
log(error.stack || String(error));
|
|
1675
|
+
process.exitCode = 1;
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
module.exports = {
|
|
1680
|
+
processDir,
|
|
1681
|
+
buildCssHeadBlock,
|
|
1682
|
+
desktopWindowBgBridge,
|
|
1683
|
+
fixedOffsetBridgeCss,
|
|
1684
|
+
fontDescriptorToUrl,
|
|
1685
|
+
firstGoogleFontFamily,
|
|
1686
|
+
inlineSvgSprites,
|
|
1687
|
+
injectCss,
|
|
1688
|
+
injectStage,
|
|
1689
|
+
findLogo,
|
|
1690
|
+
buildCardHtml,
|
|
1691
|
+
};
|