shakers-cli-beta 0.8.0-beta.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.
Files changed (218) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1175 -0
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +81 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +120 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +283 -0
  20. package/bin/mcp.js +98 -0
  21. package/bin/me.js +69 -0
  22. package/bin/onboarding.js +134 -0
  23. package/bin/portfolios.js +69 -0
  24. package/bin/rate.js +70 -0
  25. package/bin/register.js +325 -0
  26. package/bin/report-html.js +134 -0
  27. package/bin/save.js +77 -0
  28. package/bin/shakers.js +344 -0
  29. package/bin/share.js +72 -0
  30. package/bin/show-project.js +72 -0
  31. package/bin/socials.js +70 -0
  32. package/bin/superadmin.js +351 -0
  33. package/package.json +40 -0
  34. package/src/agent-category.js +13 -0
  35. package/src/agent-evaluation.js +177 -0
  36. package/src/agent-org-chart.js +355 -0
  37. package/src/agent-synthesis.js +132 -0
  38. package/src/agent-usage.js +118 -0
  39. package/src/agents-client.js +165 -0
  40. package/src/ai-fluency-cell.js +72 -0
  41. package/src/ai-profile-client.js +45 -0
  42. package/src/ai-profile-preview.js +154 -0
  43. package/src/ai.js +66 -0
  44. package/src/alma-client.js +123 -0
  45. package/src/alma-flow.js +32 -0
  46. package/src/alma-repo-context.js +47 -0
  47. package/src/alma-shell.js +161 -0
  48. package/src/ansi.js +62 -0
  49. package/src/applications-client.js +79 -0
  50. package/src/applications-flow.js +58 -0
  51. package/src/auth-client.js +144 -0
  52. package/src/auth-device-client.js +118 -0
  53. package/src/auth-method-picker.js +44 -0
  54. package/src/auth-methods.js +35 -0
  55. package/src/auth-session-store.js +214 -0
  56. package/src/authorship.js +203 -0
  57. package/src/automations-detector.js +170 -0
  58. package/src/availability-client.js +92 -0
  59. package/src/availability-flow.js +105 -0
  60. package/src/backend-request.js +170 -0
  61. package/src/blind-approval-extract.js +48 -0
  62. package/src/brand-ansi.js +14 -0
  63. package/src/brand-svg.js +9 -0
  64. package/src/browser-tools-detector.js +21 -0
  65. package/src/build-next-level.js +56 -0
  66. package/src/certifications-client.js +89 -0
  67. package/src/certifications-flow.js +75 -0
  68. package/src/certify-dimension-client.js +217 -0
  69. package/src/certify-dimension-flow.js +193 -0
  70. package/src/certify-disclaimer.js +51 -0
  71. package/src/certify-remediation-prompt.js +23 -0
  72. package/src/cli-args.js +83 -0
  73. package/src/config-dir.js +120 -0
  74. package/src/config.js +778 -0
  75. package/src/consent-flow.js +115 -0
  76. package/src/consent-skip.js +27 -0
  77. package/src/decisions-extract.js +43 -0
  78. package/src/detect-repos.js +145 -0
  79. package/src/detected-tools.js +15 -0
  80. package/src/detection-evidence.js +105 -0
  81. package/src/detection-noise.js +132 -0
  82. package/src/detectors.js +221 -0
  83. package/src/device-auth-store.js +100 -0
  84. package/src/device-login.js +118 -0
  85. package/src/email-verification.js +152 -0
  86. package/src/env-paths.js +16 -0
  87. package/src/find-projects-cache.js +89 -0
  88. package/src/find-projects-client.js +138 -0
  89. package/src/find-projects-flow.js +164 -0
  90. package/src/git-activity.js +123 -0
  91. package/src/graph-certs.js +58 -0
  92. package/src/graph-generator.js +230 -0
  93. package/src/graph-scan.js +239 -0
  94. package/src/i18n.js +3264 -0
  95. package/src/inter-fonts.js +6 -0
  96. package/src/interactive-select.js +183 -0
  97. package/src/interview-client.js +226 -0
  98. package/src/interview-transcript.js +56 -0
  99. package/src/inventory-client.js +90 -0
  100. package/src/invitations-client.js +66 -0
  101. package/src/invitations-flow.js +50 -0
  102. package/src/lang-client.js +147 -0
  103. package/src/lang-codes.js +14 -0
  104. package/src/lang-flow.js +85 -0
  105. package/src/legal-notice.js +53 -0
  106. package/src/locale.js +51 -0
  107. package/src/markdown-ansi.js +50 -0
  108. package/src/maturity.js +111 -0
  109. package/src/mcp-ai-usage-result-tool.js +109 -0
  110. package/src/mcp-ai-usage-tool.js +333 -0
  111. package/src/mcp-alma-tools.js +91 -0
  112. package/src/mcp-applications-tools.js +42 -0
  113. package/src/mcp-ask-tools.js +50 -0
  114. package/src/mcp-auth-form.js +282 -0
  115. package/src/mcp-availability-tools.js +43 -0
  116. package/src/mcp-certifications-tools.js +42 -0
  117. package/src/mcp-certify-dimension-tools.js +58 -0
  118. package/src/mcp-detector.js +223 -0
  119. package/src/mcp-device-login-tools.js +173 -0
  120. package/src/mcp-find-projects-tools.js +67 -0
  121. package/src/mcp-install.js +245 -0
  122. package/src/mcp-invitations-tools.js +42 -0
  123. package/src/mcp-lang-tools.js +39 -0
  124. package/src/mcp-onboarding-tools.js +388 -0
  125. package/src/mcp-portfolios-tools.js +46 -0
  126. package/src/mcp-profile-tools.js +46 -0
  127. package/src/mcp-rate-tools.js +42 -0
  128. package/src/mcp-register-tools.js +517 -0
  129. package/src/mcp-report-tools.js +101 -0
  130. package/src/mcp-role-tools.js +112 -0
  131. package/src/mcp-saved-positions-tools.js +55 -0
  132. package/src/mcp-server.js +170 -0
  133. package/src/mcp-show-project-tools.js +50 -0
  134. package/src/mcp-socials-tools.js +39 -0
  135. package/src/memory-structure-detector.js +108 -0
  136. package/src/model-call-record.js +198 -0
  137. package/src/onboarding-availability.js +26 -0
  138. package/src/onboarding-client.js +364 -0
  139. package/src/onboarding-flow.js +1078 -0
  140. package/src/onboarding-livekit-client.js +102 -0
  141. package/src/onboarding-status-client.js +42 -0
  142. package/src/open-file.js +32 -0
  143. package/src/osc-link.js +16 -0
  144. package/src/portfolio-client.js +164 -0
  145. package/src/portfolio-git-info.js +61 -0
  146. package/src/portfolios-client.js +85 -0
  147. package/src/portfolios-flow.js +79 -0
  148. package/src/profile-client.js +125 -0
  149. package/src/profile-flow.js +115 -0
  150. package/src/profile-switch.js +46 -0
  151. package/src/prompt-select.js +29 -0
  152. package/src/rate-client.js +82 -0
  153. package/src/rate-flow.js +137 -0
  154. package/src/render-ai-fluency.js +266 -0
  155. package/src/render-certification.js +177 -0
  156. package/src/render-html.js +140 -0
  157. package/src/render-report-text.js +27 -0
  158. package/src/render-sheet.js +261 -0
  159. package/src/render-terminal.js +109 -0
  160. package/src/render-usage-report-text.js +155 -0
  161. package/src/repo-context.js +215 -0
  162. package/src/report-gating.js +49 -0
  163. package/src/report-store.js +226 -0
  164. package/src/report-theme.js +177 -0
  165. package/src/report-view-client.js +50 -0
  166. package/src/reveal-output.js +29 -0
  167. package/src/roadmap-content.js +453 -0
  168. package/src/roadmap-personalization.js +132 -0
  169. package/src/roadmap-prompt.js +154 -0
  170. package/src/roles-client.js +114 -0
  171. package/src/roles-flow.js +192 -0
  172. package/src/sanitize-network-text.js +36 -0
  173. package/src/saved-positions-client.js +71 -0
  174. package/src/saved-positions-flow.js +50 -0
  175. package/src/scan-exclusions.js +50 -0
  176. package/src/scanner.js +612 -0
  177. package/src/scope-signals.js +148 -0
  178. package/src/secret-prompt.js +72 -0
  179. package/src/session-gate.js +30 -0
  180. package/src/session-refresh.js +51 -0
  181. package/src/session-scan.js +336 -0
  182. package/src/session-signals.js +36 -0
  183. package/src/shakers-wordmark.js +17 -0
  184. package/src/share-card.js +362 -0
  185. package/src/share-certification.js +111 -0
  186. package/src/share-consent-state.js +157 -0
  187. package/src/share-payload.js +300 -0
  188. package/src/share.js +184 -0
  189. package/src/show-project-client.js +58 -0
  190. package/src/show-project-flow.js +119 -0
  191. package/src/signup-client.js +91 -0
  192. package/src/skills-client.js +102 -0
  193. package/src/socials-client.js +91 -0
  194. package/src/socials-flow.js +83 -0
  195. package/src/specificity-extract.js +41 -0
  196. package/src/start-add-agents.js +306 -0
  197. package/src/start-add-portfolio.js +388 -0
  198. package/src/start-add-skills.js +260 -0
  199. package/src/stdin-ask.js +129 -0
  200. package/src/steering-extract.js +43 -0
  201. package/src/strip-internal-comments.js +45 -0
  202. package/src/tech-detector.js +295 -0
  203. package/src/templates/report-sheet.html +572 -0
  204. package/src/terminal-agents.js +199 -0
  205. package/src/terminal-format.js +85 -0
  206. package/src/terminal-progress.js +106 -0
  207. package/src/terminal-sections.js +219 -0
  208. package/src/tier-analysis.js +126 -0
  209. package/src/tier-engine.js +114 -0
  210. package/src/tier-projection.js +117 -0
  211. package/src/transcript-turns.js +49 -0
  212. package/src/translations-client.js +84 -0
  213. package/src/typewriter.js +154 -0
  214. package/src/usage-model-calls.js +119 -0
  215. package/src/usage-oneshot-commands.js +180 -0
  216. package/src/usage-repo-scope.js +162 -0
  217. package/src/usage-run-steps.js +338 -0
  218. package/src/work-streams.js +74 -0
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ // repo-context.js — collect a COMPACT, content-free structural context of a codebase for the LLM analysis that builds the LOCAL report's graph (`map`).
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { scrubString } = require('./graph-generator');
8
+ const { modelNode } = require('./graph-scan'); // exact-model resolver (aliases → exact ids)
9
+
10
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo', 'vendor', '__snapshots__', '__tests__', '__mocks__', 'test', 'tests', 'e2e']);
11
+ const CODE_EXT = new Set(['.ts', '.js', '.tsx', '.jsx', '.mjs', '.cts', '.mts']);
12
+ const SKIP_FILE_RE = /\.(spec|test|e2e-spec|d)\.[tj]sx?$/;
13
+ const MAX_FILES = 6000;
14
+ const MAX_READ = 700;
15
+ const MAX_BYTES_PER = 200 * 1024;
16
+ const LINE_MAX = 160;
17
+ const CAP = { entrypoints: 12, crons: 18, agents: 30, integrations: 20, stores: 12, services: 40, tech: 60 };
18
+
19
+ const AI_RE = /generateText|streamText|generateObject|streamObject|embedMany|tool\s*\(|@ai-sdk\/|from\s+['"]ai['"]|new\s+OpenAI|new\s+Anthropic|@anthropic-ai\/sdk|GoogleGenerativeAI|@google\/(?:genai|generative-ai)|Gemini\w*Service|Anthropic\w*Service|ShakersAi\w*|\.sendMessage\(|\.messages\.create\(|\.chat\.completions\.create\(|\.generateContent\(|\.responses\.create\(|ChatOpenAI|langchain|llamaindex|createAgent|AgentExecutor/;
20
+ const CRON_RE = /@Cron\b|CronExpression|new\s+CronJob|EVERY_(MINUTE|HOUR|DAY)/;
21
+ const CRON_PATH_RE = /(\.cron\.[tj]s$|(^|\/)cron-jobs?\/|(^|\/)crons?\/)/i;
22
+ const ENTRY_PATH_RE = /(controller|\.route|router|webhook|gateway|resolver|main\.[tj]s|(^|\/)cli\/|(^|\/)commands?\/)/i;
23
+ const IMPORT_RE = /^\s*(import\b|const\b.*=\s*require\()/;
24
+
25
+ // npm import substring -> integration/provider label
26
+ const INTEG = [
27
+ [/stripe/i, 'Stripe'], [/hubspot/i, 'HubSpot'], [/(datadog|dd-trace)/i, 'Datadog'],
28
+ [/twilio/i, 'Twilio'], [/(sendgrid|@sendgrid)/i, 'SendGrid'], [/(@slack|slack-)/i, 'Slack'],
29
+ [/(bigquery|@google-cloud\/bigquery)/i, 'BigQuery'], [/holded/i, 'Holded'], [/adobe/i, 'Adobe PDF'],
30
+ [/n8n/i, 'n8n'], [/cloudfront/i, 'CloudFront'], [/(mongoose|mongodb)/i, 'MongoDB'],
31
+ [/(ioredis|['"]redis['"])/i, 'Redis'], [/@aws-sdk|aws-sdk/i, 'AWS'],
32
+ ];
33
+ // integrations/<dir> -> label (a dir under any `integrations/` path = a wired service)
34
+ const INTEG_DIR = {
35
+ hubspot: 'HubSpot', holded: 'Holded', slack: 'Slack', datadog: 'Datadog', bigquery: 'BigQuery',
36
+ adobe: 'Adobe PDF', 'adobe-pdf': 'Adobe PDF', n8n: 'n8n', cloudfront: 'CloudFront', s3: 'AWS S3',
37
+ stripe: 'Stripe', match: 'calc-match', 'shakers-ai': 'shakers-ai-api', 'sh-ai': 'shakers-ai-api',
38
+ twilio: 'Twilio', sendgrid: 'SendGrid',
39
+ };
40
+ const MODEL_DIRS = new Set(['gemini', 'anthropic', 'openai']); // integration dirs that are MODELS, not externals
41
+
42
+ function slug(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'x'; }
43
+ function clip(s) { s = String(s || '').trim(); return s.length > LINE_MAX ? s.slice(0, LINE_MAX) + '…' : s; }
44
+ function titleize(s) { return String(s || '').replace(/[-_]+/g, ' ').replace(/\.(service|controller|cron|module)$/i, '').trim().replace(/\b\w/g, (c) => c.toUpperCase()).slice(0, 40); }
45
+ function readSafe(p) { try { const st = fs.statSync(p); if (st.size > MAX_BYTES_PER) return null; return fs.readFileSync(p, 'utf8'); } catch { return null; } }
46
+
47
+ function providerFromLine(line) {
48
+ const l = String(line || '').toLowerCase();
49
+ if (/gemini|generatecontent|google.*genai|generativeai/.test(l)) return { label: 'Gemini', domain: 'gemini.google.com' };
50
+ if (/anthropic|claude|messages\.create/.test(l)) return { label: 'Claude', domain: 'claude.ai' };
51
+ if (/openai|chat\.completions|responses\.create|\bgpt\b/.test(l)) return { label: 'OpenAI', domain: 'openai.com' };
52
+ if (/shakersai|sh_ai|shakers-ai|chatgpt/.test(l)) return { label: 'shakers-ai-api', domain: null };
53
+ return null;
54
+ }
55
+
56
+ function walk(root) {
57
+ const files = [];
58
+ const stack = [root];
59
+ while (stack.length && files.length < MAX_FILES) {
60
+ const dir = stack.pop();
61
+ let entries;
62
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
63
+ for (const e of entries) {
64
+ if (files.length >= MAX_FILES) break;
65
+ if (e.name.startsWith('.') && e.name !== '.claude') continue;
66
+ const full = path.join(dir, e.name);
67
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
68
+ else if (CODE_EXT.has(path.extname(e.name)) && !SKIP_FILE_RE.test(e.name)) files.push(full);
69
+ }
70
+ }
71
+ return files;
72
+ }
73
+
74
+ function entryGroup(rel) {
75
+ const p = rel.toLowerCase();
76
+ const app = (p.match(/(?:^|\/)apps\/([^/]+)\//) || [])[1];
77
+ if (app) return { key: 'app:' + app, label: titleize(app) + ' API', sub: `apps/${app}` };
78
+ const integ = (p.match(/(?:^|\/)integrations\/([^/]+)\/.*(webhook|controller)/) || [])[1];
79
+ if (integ) return { key: 'wh:' + integ, label: titleize(integ) + ' webhook', sub: 'inbound webhook' };
80
+ if (/gateway|websocket|\bws\b/.test(p)) return { key: 'ws', label: 'WebSocket gateway', sub: 'realtime' };
81
+ if (/(^|\/)cli\/|(^|\/)commands?\//.test(p)) return { key: 'cli', label: 'CLI', sub: 'commands' };
82
+ if (/main\.[tj]s$/.test(p)) return { key: 'http', label: 'HTTP server', sub: 'main' };
83
+ if (/webhook/.test(p)) return { key: 'wh', label: 'Webhook', sub: 'inbound' };
84
+ return { key: 'ctrl', label: 'HTTP API', sub: 'controllers' };
85
+ }
86
+
87
+ function collectRepoContext(root, { readFileImpl = readSafe } = {}) {
88
+ const abs = path.resolve(root || process.cwd());
89
+ const rel = (p) => path.relative(abs, p) || path.basename(p);
90
+
91
+ const ctx = {
92
+ project: { name: scrubString(path.basename(abs) || 'project'), slug: slug(path.basename(abs)), date: new Date().toISOString().slice(0, 10) },
93
+ entrypoints: [], crons: [], agents: [], services: [], models: [], integrations: [], stores: [], technologies: [],
94
+ };
95
+ const integrationSet = new Set();
96
+ const modelSet = new Map();
97
+ const storeSet = new Set();
98
+ const serviceSet = new Set();
99
+ const entryGroups = new Map();
100
+ const cronFiles = new Set();
101
+ const aiFiles = new Map(); // path -> { provider, line }
102
+
103
+ const pkgRaw = readFileImpl(path.join(abs, 'package.json'));
104
+ if (pkgRaw) {
105
+ try {
106
+ const pkg = JSON.parse(pkgRaw);
107
+ if (pkg.name) { const nm = pkg.name.replace(/^@[^/]+\//, ''); ctx.project.name = scrubString(nm); ctx.project.slug = slug(nm); }
108
+ if (pkg.description) ctx.project.tagline = scrubString(clip(pkg.description));
109
+ ctx.technologies = Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }).slice(0, CAP.tech).map(scrubString);
110
+ } catch { /* ignore */ }
111
+ }
112
+
113
+ // Prisma stores (datasource provider). schema.prisma OR prisma/schema/ dir.
114
+ const eatPrisma = (raw) => {
115
+ if (!raw) return;
116
+ const provider = (raw.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/m) || [])[1];
117
+ if (provider) storeSet.add(provider === 'postgresql' ? 'PostgreSQL' : titleize(provider));
118
+ };
119
+ for (const cand of ['prisma/schema.prisma', 'schema.prisma']) { const r = readFileImpl(path.join(abs, cand)); if (r) { eatPrisma(r); break; } }
120
+ for (const dir of ['prisma/schema', 'prisma']) {
121
+ const d = path.join(abs, dir);
122
+ try { if (fs.statSync(d).isDirectory()) for (const f of fs.readdirSync(d)) if (f.endsWith('.prisma')) eatPrisma(readFileImpl(path.join(d, f))); } catch { /* none */ }
123
+ }
124
+
125
+ try {
126
+ for (const f of fs.readdirSync(path.join(abs, '.claude', 'agents'))) {
127
+ if (!f.endsWith('.md')) continue;
128
+ const raw = readFileImpl(path.join(abs, '.claude', 'agents', f)) || '';
129
+ const mm = raw.match(/^\s*model\s*:\s*["']?([^"'\n]+?)["']?\s*$/im);
130
+ const mn = mm ? modelNode(mm[1].trim()) : null;
131
+ const provider = mn ? mn.label : null;
132
+ if (mn) modelSet.set(mn.label, { label: mn.label, ...(mn.domain ? { domain: mn.domain } : {}) });
133
+ ctx.agents.push({ label: titleize(path.basename(f, '.md')), provider, path: scrubString(`.claude/agents/${f}`) });
134
+ }
135
+ } catch { /* none */ }
136
+
137
+ // scan code files — AI/flow-likely first
138
+ const score = (p) => {
139
+ if (/(gemini|openai|anthropic|claude|ai-footprint|\bai\b|\/ai[-/]|llm|agent|orchestrat|generate-|chat|embed|personaliz|translat|synthes|certif|recomm|summar|extract|classif|briefing|showcase)/i.test(p)) return 0;
140
+ if (/(\.service\.|controller|\.cron|worker|queue|webhook|gateway|resolver|handler|integrations\/)/i.test(p)) return 1;
141
+ return 2;
142
+ };
143
+ const files = walk(abs).sort((a, b) => score(rel(a)) - score(rel(b)));
144
+ let readCount = 0;
145
+ for (const f of files) {
146
+ const r = rel(f);
147
+ // integration dirs → externals or models (KNOWN dirs only, to avoid noise
148
+ // from generic subdirs like ai/chat/http/config)
149
+ const idir = (r.match(/(?:^|\/)integrations\/([^/]+)\//) || [])[1];
150
+ if (idir) {
151
+ const key = idir.toLowerCase();
152
+ if (MODEL_DIRS.has(key)) { const pv = providerFromLine(key); if (pv) modelSet.set(pv.label, pv); }
153
+ else if (INTEG_DIR[key]) integrationSet.add(INTEG_DIR[key]);
154
+ }
155
+ // module/service dirs
156
+ const mod = (r.match(/(?:^|\/)modules\/([^/]+)\//) || [])[1];
157
+ if (mod && !/^(prisma|mongo|base-http|response|counter|shared|common|health)$/i.test(mod)) serviceSet.add(mod);
158
+ // datastore hints from path/libs
159
+ if (/aws-sdk\/client-s3|(^|\/)s3\b/i.test(r)) storeSet.add('AWS S3');
160
+ if (/cloudfront/i.test(r)) storeSet.add('CloudFront KVS');
161
+ // entrypoints (grouped)
162
+ if (ENTRY_PATH_RE.test(r) && !CRON_PATH_RE.test(r)) { const g = entryGroup(r); if (!entryGroups.has(g.key)) entryGroups.set(g.key, g); }
163
+ // crons (per file, by path)
164
+ if (CRON_PATH_RE.test(r)) cronFiles.add(r);
165
+
166
+ if (readCount >= MAX_READ) continue;
167
+ const src = readFileImpl(f);
168
+ if (src == null) continue;
169
+ readCount++;
170
+ const lines = src.split('\n');
171
+ let firstAi = null;
172
+ for (const ln of lines) {
173
+ if (!firstAi && AI_RE.test(ln)) firstAi = ln;
174
+ if (CRON_RE.test(ln)) cronFiles.add(r);
175
+ if (IMPORT_RE.test(ln)) {
176
+ for (const [re, label] of INTEG) if (re.test(ln)) {
177
+ if (/mongo/i.test(label)) storeSet.add('MongoDB');
178
+ else if (/redis/i.test(label)) storeSet.add('Redis');
179
+ else if (label === 'AWS') storeSet.add('AWS S3');
180
+ else integrationSet.add(label);
181
+ }
182
+ const pv = providerFromLine(ln);
183
+ if (pv && pv.domain) modelSet.set(pv.label, pv);
184
+ }
185
+ }
186
+ if (firstAi) aiFiles.set(r, { provider: providerFromLine(firstAi), line: clip(firstAi) });
187
+ }
188
+
189
+ const AGENT_SKIP_DIR = /^(gemini|anthropic|openai|http|base-http|event|config|util|shared|common|prisma|mongo|health|logger|integrations)$/i;
190
+ for (const [p, info] of aiFiles) {
191
+ if (ctx.agents.length >= CAP.agents) break;
192
+ if (/(?:^|\/)integrations\//.test(p)) { // the provider client, not an agent
193
+ if (info.provider && info.provider.domain) modelSet.set(info.provider.label, info.provider);
194
+ continue;
195
+ }
196
+ const dir = p.split('/').slice(-2, -1)[0] || path.basename(p);
197
+ if (AGENT_SKIP_DIR.test(dir)) continue;
198
+ ctx.agents.push({ label: titleize(dir), provider: info.provider ? info.provider.label : null, path: scrubString(p) });
199
+ if (info.provider && info.provider.domain) modelSet.set(info.provider.label, info.provider);
200
+ else if (info.provider) integrationSet.add(info.provider.label); // shakers-ai-api = external tool
201
+ }
202
+ const hasApp = [...entryGroups.keys()].some((k) => k.startsWith('app:'));
203
+ const hasNamedWebhook = [...entryGroups.keys()].some((k) => k.startsWith('wh:'));
204
+ if (hasApp) { entryGroups.delete('ctrl'); entryGroups.delete('http'); }
205
+ if (hasNamedWebhook) entryGroups.delete('wh');
206
+ ctx.entrypoints = Array.from(entryGroups.values()).slice(0, CAP.entrypoints).map((g) => ({ label: g.label, sub: g.sub }));
207
+ ctx.crons = Array.from(cronFiles).slice(0, CAP.crons).map((p) => ({ label: titleize(path.basename(p).replace(/\.(cron\.)?[tj]s$/, '')), path: scrubString(p) }));
208
+ ctx.services = Array.from(serviceSet).slice(0, CAP.services).map((m) => titleize(m));
209
+ ctx.models = Array.from(modelSet.values());
210
+ ctx.integrations = Array.from(integrationSet).slice(0, CAP.integrations);
211
+ ctx.stores = Array.from(storeSet).slice(0, CAP.stores);
212
+ return ctx;
213
+ }
214
+
215
+ module.exports = { collectRepoContext, slug };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const { loadAuthSession, sessionStatus, isPlatformTalent } = require('./auth-session-store');
4
+ const { getProfile } = require('./config');
5
+
6
+ // THE ONE LAYER THAT DECIDES WHAT THE REPORT SHOWS ACCORDING TO IDENTITY (talents-ai-score, issue 123 / ADR-044).
7
+
8
+ // Pure: identity in, policy out.
9
+ function resolveReportGate({ loggedIn = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
10
+ const showEverything = !loggedIn;
11
+ const resolvedProfile = profile === 'external' ? 'external' : 'talent';
12
+ return Object.freeze({
13
+ loggedIn: !!loggedIn,
14
+ profile: resolvedProfile,
15
+ sessionStatus: status,
16
+ email: loggedIn && typeof email === 'string' && email ? email : null,
17
+ // --roadmap AND its server-side computation/egress (see bin/report.js): a
18
+ // logged-in Talent's roadmap is not merely hidden, it is not requested.
19
+ showRoadmap: showEverything,
20
+ // Per-agent "how to improve this agent" tips in the footprint report.
21
+ showAgentSuggestions: showEverything,
22
+ // The copyable remediation prompt in a `certify` verdict.
23
+ showCertifyRemediation: showEverything,
24
+ showFrameworkIntro: resolvedProfile === 'talent' && loggedIn && isPlatformTalent({ userType }),
25
+ });
26
+ }
27
+
28
+ // The general-user policy, used when a renderer is called without a gate. Frozen
29
+ // and shared so callers cannot accidentally mutate the default for everyone.
30
+ const DEFAULT_REPORT_GATE = resolveReportGate({ loggedIn: false });
31
+
32
+ // Reads the auth session and resolves the policy.
33
+ function reportGateForIdentity(env = process.env, now = Date.now()) {
34
+ const session = loadAuthSession(env);
35
+ const status = sessionStatus(session, now);
36
+ return resolveReportGate({
37
+ loggedIn: status === 'active',
38
+ status,
39
+ email: session ? session.email : null,
40
+ userType: session ? session.userType : null,
41
+ profile: getProfile(env),
42
+ });
43
+ }
44
+
45
+ module.exports = {
46
+ resolveReportGate,
47
+ reportGateForIdentity,
48
+ DEFAULT_REPORT_GATE,
49
+ };
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { pathToFileURL } = require('url');
7
+
8
+ const { getConfigDir } = require('./config-dir');
9
+ const { renderSheet } = require('./render-sheet');
10
+ const { reportGateForIdentity } = require('./report-gating');
11
+
12
+ const SCHEMA_VERSION = 2;
13
+
14
+ function configDir() {
15
+ return getConfigDir(process.env);
16
+ }
17
+ function statePath() {
18
+ return path.join(configDir(), 'report-state.json');
19
+ }
20
+
21
+ // Stable, filesystem-safe per-project file name derived from the absolute path.
22
+ function projectSlug(absRoot) {
23
+ return crypto.createHash('sha1').update(String(absRoot)).digest('hex').slice(0, 12);
24
+ }
25
+ function htmlPathFor(absRoot) {
26
+ return path.join(configDir(), `report-${projectSlug(path.resolve(absRoot))}.html`);
27
+ }
28
+
29
+ // A real file:// URL (handles spaces, Windows drive letters, etc.) so the CLI
30
+ // can print a link the talent can click/paste to open the report.
31
+ function fileUrl(p) {
32
+ return pathToFileURL(p).href;
33
+ }
34
+
35
+ function freshState() {
36
+ return { schemaVersion: SCHEMA_VERSION, updatedAt: null, projects: {} };
37
+ }
38
+
39
+ function migrateFromV1(parsed) {
40
+ const projects = {};
41
+ const legacyFootprints = parsed && parsed.footprints && typeof parsed.footprints === 'object'
42
+ ? parsed.footprints
43
+ : {};
44
+ for (const [absRoot, fp] of Object.entries(legacyFootprints)) {
45
+ projects[absRoot] = {
46
+ root: (fp && fp.root) || absRoot,
47
+ updatedAt: (fp && fp.generatedAt) || null,
48
+ footprint: fp
49
+ ? { generatedAt: fp.generatedAt || null, report: fp.report, maturity: fp.maturity }
50
+ : null,
51
+ certifications: {},
52
+ };
53
+ }
54
+ return { schemaVersion: SCHEMA_VERSION, updatedAt: (parsed && parsed.updatedAt) || null, projects };
55
+ }
56
+
57
+ function loadState() {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
61
+ } catch {
62
+ return freshState();
63
+ }
64
+ if (parsed && parsed.schemaVersion === SCHEMA_VERSION && parsed.projects && typeof parsed.projects === 'object') {
65
+ return { schemaVersion: SCHEMA_VERSION, updatedAt: parsed.updatedAt || null, projects: parsed.projects };
66
+ }
67
+ // Any older / unknown shape (v1 global model, or garbage) -> migrate what we can.
68
+ return migrateFromV1(parsed);
69
+ }
70
+
71
+ function saveState(state) {
72
+ fs.mkdirSync(configDir(), { recursive: true });
73
+ fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));
74
+ }
75
+
76
+ function getOrCreateProject(state, absRoot) {
77
+ if (!state.projects[absRoot]) {
78
+ state.projects[absRoot] = {
79
+ root: absRoot,
80
+ updatedAt: null,
81
+ footprint: null,
82
+ certifications: {},
83
+ backendAcceptance: {},
84
+ };
85
+ }
86
+ // Backward-compat: a project persisted before the two-backend chain
87
+ // existed (talents-ai-score, ADR-020/021).
88
+ if (!state.projects[absRoot].backendAcceptance) {
89
+ state.projects[absRoot].backendAcceptance = {};
90
+ }
91
+ return state.projects[absRoot];
92
+ }
93
+
94
+ /* ---------- rendering a SINGLE project's document ---------- */
95
+
96
+ function renderProjectHtml(project, lang, gate) {
97
+ return renderSheet(project, lang, gate);
98
+ }
99
+
100
+ /* ---------- persist (state only) + materialize (render HTML) ---------- */
101
+ // ADR-016 split: `footprint` and `certify` now PERSIST state only (no HTML file, no printed link).
102
+
103
+ function stampAndSaveState(state, absRoot) {
104
+ state.schemaVersion = SCHEMA_VERSION;
105
+ const now = new Date().toISOString();
106
+ state.updatedAt = now;
107
+ state.projects[absRoot].updatedAt = now;
108
+ saveState(state);
109
+ return { statePath: statePath(), stateDir: configDir() };
110
+ }
111
+
112
+ function writeProjectHtml(project, absRoot, lang, gate) {
113
+ const html = renderProjectHtml(project, lang, gate);
114
+ fs.mkdirSync(configDir(), { recursive: true });
115
+ const p = htmlPathFor(absRoot);
116
+ fs.writeFileSync(p, html);
117
+ return { htmlPath: p, fileUrl: fileUrl(p) };
118
+ }
119
+
120
+ // Persist THIS project's footprint into report-state.json (keyed by absolute project path).
121
+ function persistFootprint({ root, report, maturity }) {
122
+ const absRoot = path.resolve(root || process.cwd());
123
+ const state = loadState();
124
+ const project = getOrCreateProject(state, absRoot);
125
+ project.footprint = {
126
+ generatedAt: (report && report.generatedAt) || new Date().toISOString(),
127
+ report,
128
+ maturity,
129
+ };
130
+ return stampAndSaveState(state, absRoot);
131
+ }
132
+
133
+ function loadAgentClassifications(root) {
134
+ const absRoot = path.resolve(root || process.cwd());
135
+ const state = loadState();
136
+ const project = state.projects && state.projects[absRoot];
137
+ const evaluation = project && project.footprint && project.footprint.report && project.footprint.report.agentEvaluation;
138
+ const evaluations = evaluation && Array.isArray(evaluation.evaluations) ? evaluation.evaluations : [];
139
+ const byName = {};
140
+ for (const e of evaluations) {
141
+ if (e && typeof e.name === 'string' && e.name && e.classification) {
142
+ byName[e.name] = e.classification;
143
+ }
144
+ }
145
+ return byName;
146
+ }
147
+
148
+ function recordBackendAcceptance({ root, kind, backend }) {
149
+ if (!kind || !backend) return null;
150
+ const absRoot = path.resolve(root || process.cwd());
151
+ const state = loadState();
152
+ const project = getOrCreateProject(state, absRoot);
153
+ project.backendAcceptance[kind] = { backend, at: new Date().toISOString() };
154
+ return stampAndSaveState(state, absRoot);
155
+ }
156
+
157
+ function loadFootprint({ root } = {}) {
158
+ const absRoot = path.resolve(root || process.cwd());
159
+ const state = loadState();
160
+ const project = state.projects && state.projects[absRoot];
161
+ const fp = project && project.footprint;
162
+ return fp && fp.report ? { report: fp.report, maturity: fp.maturity } : null;
163
+ }
164
+
165
+ // The gate of `materializeProjectReport`, as a NAMED TABLE rather than an inlined chain of `&&`s — and the table IS the inventory.
166
+ const RENDERED_DATA = {
167
+ footprint: (p) => !!(p.footprint && p.footprint.report),
168
+ certifications: (p) => !!(p.certifications && Object.keys(p.certifications).length),
169
+ };
170
+
171
+ function hasRenderableData(project) {
172
+ if (!project) return false;
173
+ return Object.values(RENDERED_DATA).some((has) => has(project));
174
+ }
175
+
176
+ // Materialize (render + write) THIS project's cumulative HTML from persisted state, and return its path + file:// URL.
177
+ function materializeProjectReport({ root, lang }) {
178
+ const absRoot = path.resolve(root || process.cwd());
179
+ const state = loadState();
180
+ const project = state.projects[absRoot];
181
+ if (!hasRenderableData(project)) return { hasData: false };
182
+ // issue 123 / ADR-044: the shareable HTML is gated by identity AT GENERATION TIME — this is the moment the `report` command produces the file, so this is where the gate is resolved.
183
+ const gate = reportGateForIdentity();
184
+ return { hasData: true, ...writeProjectHtml(project, absRoot, lang, gate) };
185
+ }
186
+
187
+ // `upsertFootprint` AND `upsertCertification` WERE HERE, and ADR-035 retired them.
188
+
189
+ function certKey(item) {
190
+ if (item && item.skillId != null) return `id:${item.skillId}`;
191
+ const name = (item && item.skillName) || '';
192
+ const tech = (item && item.technology) || '';
193
+ return `nt:${name}::${tech}`;
194
+ }
195
+
196
+ // Upsert each certified Skill from this run into THIS PROJECT's report (keyed by Skill id within the project).
197
+ function persistCertification({ root, items }) {
198
+ const absRoot = path.resolve(root || process.cwd());
199
+ const list = Array.isArray(items) ? items.filter((i) => i && (i.skillId != null || i.skillName)) : [];
200
+ const state = loadState();
201
+ const project = getOrCreateProject(state, absRoot);
202
+ const now = new Date().toISOString();
203
+ for (const item of list) {
204
+ project.certifications[certKey(item)] = { generatedAt: now, item };
205
+ }
206
+ return stampAndSaveState(state, absRoot);
207
+ }
208
+
209
+ module.exports = {
210
+ configDir,
211
+ statePath,
212
+ htmlPathFor,
213
+ projectSlug,
214
+ fileUrl,
215
+ loadState,
216
+ saveState,
217
+ renderProjectHtml,
218
+ persistFootprint,
219
+ persistCertification,
220
+ loadAgentClassifications,
221
+ loadFootprint,
222
+ recordBackendAcceptance,
223
+ materializeProjectReport,
224
+ certKey,
225
+ SCHEMA_VERSION,
226
+ };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ function esc(s) {
4
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({
5
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
6
+ }[ch]));
7
+ }
8
+
9
+ /* ---------- design tokens (Shakers "Nexia", light-only, WHITE bg) ---------- */
10
+
11
+ const TOKENS_CSS = `
12
+ :root{
13
+ /* Layer 1 — brand primitives (subset used), verbatim from the DS */
14
+ --ds-teal-50:#e2f2f0; --ds-teal-100:#c5e5e1; --ds-teal-300:#51b1a5;
15
+ --ds-teal-400:#269787; --ds-teal-500:#0e7d69; --ds-teal-600:#0b5a4c;
16
+ --ds-teal-700:#08473c; --ds-teal-800:#05342c; --ds-teal-900:#03211c;
17
+ --ds-lime-200:#f5ff96; --ds-lime-500:#d8e637; --ds-lime-600:#b0bd2d;
18
+ --ds-zinc-50:#fafafa; --ds-zinc-100:#f4f4f5; --ds-zinc-200:#e4e4e7;
19
+ --ds-zinc-300:#d4d4d8; --ds-zinc-400:#a1a1aa; --ds-zinc-500:#71717a;
20
+ --ds-zinc-600:#52525b; --ds-zinc-700:#3f3f46; --ds-zinc-800:#27272a; --ds-zinc-900:#18181b;
21
+ --ds-zinc-950:#09090b; --ds-white:#ffffff;
22
+ /* DS status palette (design-spec/tokens.css) — used for score bands and
23
+ the "partial sample" warning; functional status colors, not invented. */
24
+ --ds-success:#059669; --ds-success-fg:#ecfdf5;
25
+ --ds-warning:#f59e0b; --ds-warning-fg:#fffdf5;
26
+ --ds-destructive:#e11d48; --ds-destructive-fg:#fef2f2;
27
+
28
+ /* Radii ("Border Radius" frame) */
29
+ --r-sm:6px; --r-md:8px; --r-lg:10px; --r-xl:14px; --r-full:9999px;
30
+ /* Shadows (shadcn upstream) */
31
+ --shadow-sm:0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);
32
+ --shadow-md:0 1px 3px 0 rgb(0 0 0 / .1), 0 2px 4px -1px rgb(0 0 0 / .1);
33
+ --shadow-lg:0 1px 3px 0 rgb(0 0 0 / .1), 0 4px 6px -1px rgb(0 0 0 / .1);
34
+
35
+ /* Inter with a system fallback (no @font-face, no network): if Inter isn't
36
+ installed it degrades to the DS's own fallback stack. */
37
+ --font-sans:"Inter",ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,
38
+ "Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,
39
+ "Apple Color Emoji","Segoe UI Emoji";
40
+ --font-mono:ui-monospace,"SF Mono","Cascadia Code","JetBrains Mono",Menlo,Consolas,monospace;
41
+
42
+ /* Layer 2 — semantic (light theme, the ONLY theme). Background is WHITE
43
+ (the DS's --color-background), priority #1. */
44
+ --bg:var(--ds-white);
45
+ --surface:var(--ds-white);
46
+ --fg:var(--ds-zinc-900);
47
+ --muted:var(--ds-zinc-700);
48
+ /* zinc-600 (not zinc-500) for caption/label text: clears WCAG AA on white. */
49
+ --faint:var(--ds-zinc-600);
50
+ --border:var(--ds-zinc-200);
51
+ --primary:var(--ds-teal-800);
52
+ --primary-fg:var(--ds-zinc-50);
53
+ --secondary:var(--ds-teal-50);
54
+ --secondary-fg:var(--ds-teal-600);
55
+ --emphasis:var(--ds-teal-500);
56
+ --emphasis-strong:var(--ds-teal-600);
57
+ --accent-lime:var(--ds-lime-500);
58
+ --accent-lime-fg:var(--ds-teal-800);
59
+ /* Model chip: an opaque lime tint over the surface, dark teal text. */
60
+ --model-bg:color-mix(in srgb,var(--ds-lime-500) 26%, var(--surface));
61
+ --model-fg:var(--ds-teal-800);
62
+ --off:var(--ds-zinc-300);
63
+ --track:var(--ds-zinc-100);
64
+ --ring:var(--ds-teal-500);
65
+ /* Score bands (certification): DS status colors. */
66
+ --band-high-bg:var(--ds-success); --band-high-fg:var(--ds-success-fg);
67
+ --band-mid-bg:var(--ds-warning); --band-mid-fg:var(--ds-warning-fg);
68
+ --band-low-bg:var(--ds-destructive); --band-low-fg:var(--ds-destructive-fg);
69
+ }
70
+ `;
71
+
72
+ /* ---------- shared base + primitives (used by every report) ---------- */
73
+
74
+ const BASE_CSS = `
75
+ *{box-sizing:border-box}
76
+ html{color-scheme:light}
77
+ body{margin:0;background:var(--bg);color:var(--fg);
78
+ font-family:var(--font-sans);line-height:1.45;
79
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
80
+ padding:40px 20px 64px;}
81
+ .wrap{max-width:840px;margin:0 auto;overflow-x:clip}
82
+ .card{background:var(--surface);border:1px solid var(--border);
83
+ border-radius:var(--r-lg);box-shadow:var(--shadow-sm)}
84
+
85
+ /* Header */
86
+ header{margin-bottom:24px}
87
+ .badge{display:inline-flex;align-items:center;gap:8px;
88
+ background:var(--secondary);color:var(--secondary-fg);
89
+ font-size:12px;font-weight:600;letter-spacing:.02em;
90
+ padding:5px 12px;border-radius:var(--r-full)}
91
+ .badge .spark{width:7px;height:7px;border-radius:50%;background:var(--emphasis)}
92
+ h1{font-size:clamp(28px,5vw,36px);font-weight:700;letter-spacing:-.02em;
93
+ line-height:1.15;margin:16px 0 6px}
94
+ .sub{color:var(--muted);font-size:16px;margin:0}
95
+
96
+ /* Section scaffolding */
97
+ section{margin-bottom:24px}
98
+ .h2{font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
99
+ color:var(--faint);margin:0 0 12px 2px}
100
+
101
+ /* Chips */
102
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
103
+ .chip{font-size:11px;font-weight:500;letter-spacing:.02em;color:var(--secondary-fg);
104
+ background:var(--secondary);padding:3px 10px;border-radius:var(--r-full);
105
+ flex:none;white-space:nowrap}
106
+ .chip.empty{background:transparent;color:var(--faint);padding-left:0}
107
+
108
+ /* Footer */
109
+ footer{margin-top:28px;color:var(--faint);font-size:12.5px;line-height:1.55}
110
+ .priv{display:flex;gap:12px;padding:16px 18px;border-radius:var(--r-lg);
111
+ background:var(--secondary);color:var(--secondary-fg);margin-bottom:16px}
112
+ .priv .lock{flex:none;font-size:16px;line-height:1.4}
113
+ .meta-line{font-family:var(--font-mono);font-size:11.5px}
114
+ .meta-line code{color:var(--emphasis-strong);font-weight:600}
115
+ details{margin-top:14px}
116
+ details summary{cursor:pointer;color:var(--muted);font-weight:500;
117
+ padding:8px 0;user-select:none}
118
+ details summary:hover{color:var(--fg)}
119
+ pre{background:var(--bg);border:1px solid var(--border);border-radius:var(--r-md);
120
+ padding:14px;overflow:auto;font-family:var(--font-mono);font-size:11.5px;
121
+ color:var(--muted);max-height:300px}
122
+
123
+ a:focus-visible,summary:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
124
+
125
+ @media (prefers-reduced-motion:reduce){
126
+ *{animation:none!important;transition:none!important}
127
+ }
128
+ `;
129
+
130
+ // Generic, zero-network clipboard helper shared by every report.
131
+ const COPY_SCRIPT = `
132
+ (function(){
133
+ function fallbackCopy(text){
134
+ var ta=document.createElement('textarea');ta.value=text;ta.setAttribute('readonly','');
135
+ ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();
136
+ try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);
137
+ }
138
+ document.querySelectorAll('[data-copy-target]').forEach(function(btn){
139
+ btn.addEventListener('click',function(){
140
+ var target=document.getElementById(btn.getAttribute('data-copy-target'));
141
+ if(!target)return;var text=target.textContent;
142
+ var showCopied=function(){
143
+ var original=btn.getAttribute('data-original-label')||btn.textContent;
144
+ btn.setAttribute('data-original-label',original);
145
+ btn.textContent=btn.getAttribute('data-copied-label')||original;
146
+ btn.classList.add('copied');
147
+ setTimeout(function(){btn.textContent=original;btn.classList.remove('copied');},1800);
148
+ };
149
+ if(navigator.clipboard&&navigator.clipboard.writeText){
150
+ navigator.clipboard.writeText(text).then(showCopied,function(){fallbackCopy(text);showCopied();});
151
+ }else{fallbackCopy(text);showCopied();}
152
+ });
153
+ });
154
+ })();
155
+ `;
156
+
157
+ // Assembles a full, self-contained HTML document from the shared theme plus a report's own component CSS, body markup, and (optional) extra script.
158
+ function renderDocument({ lang, title, componentCss = '', body, script = '' }) {
159
+ const scriptTag = (COPY_SCRIPT + '\n' + script).trim();
160
+ return `<!DOCTYPE html>
161
+ <html lang="${esc(lang)}">
162
+ <head>
163
+ <meta charset="utf-8">
164
+ <meta name="viewport" content="width=device-width, initial-scale=1">
165
+ <title>${esc(title)}</title>
166
+ <style>${TOKENS_CSS}${BASE_CSS}${componentCss}</style>
167
+ </head>
168
+ <body>
169
+ <div class="wrap">
170
+ ${body}
171
+ </div>
172
+ <script>${scriptTag}</script>
173
+ </body>
174
+ </html>`;
175
+ }
176
+
177
+ module.exports = { esc, renderDocument, TOKENS_CSS, BASE_CSS, COPY_SCRIPT };