@golden-frijoles/kit 0.42.0 → 0.43.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/dist/one-pagers.mjs +466 -0
- package/package.json +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// one-pagers.mjs — three sheets a maker can consult in seconds, derived from the strategy files (coaches-v2 D10).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/one-pagers.mjs render into Roadmap/00-strategy/one-pagers/ (.html and .md each)
|
|
5
|
+
// node scripts/one-pagers.mjs --out <dir> …somewhere else
|
|
6
|
+
//
|
|
7
|
+
// The sheets: a business model canvas, a value proposition sheet and a persona poster. The Strategy gate's Approve
|
|
8
|
+
// renders them, each coach renders them after its last write, and this command renders them on demand.
|
|
9
|
+
//
|
|
10
|
+
// ── Derived, never typed ──────────────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Every line on a sheet comes from a strategy file; nothing here holds strategy. A sheet typed by hand drifts from the
|
|
12
|
+
// narrative the day after it is printed, and then two documents disagree about the product (the 2026-10-05 reference
|
|
13
|
+
// sheets were transcribed, and needed a revision the next morning). A field the files don't fill says "Not written
|
|
14
|
+
// yet" rather than being guessed.
|
|
15
|
+
//
|
|
16
|
+
// ── Honest labels ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Each claim shows `true today`, `aspirational`, `agreed`, `sourced` or `hypothesis`, taken from the `(…)` label the
|
|
18
|
+
// coach wrote at the end of the line. A persona line with NO label renders as `hypothesis`: an unlabelled line is an
|
|
19
|
+
// unchecked one, and the poster must never present an anecdote as a researched frustration (dogfood F34). A sheet
|
|
20
|
+
// whose source file is still `status: draft` is watermarked "Draft".
|
|
21
|
+
//
|
|
22
|
+
// ── Licences ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// The Business Model Canvas is Strategyzer AG's, licensed CC BY-SA 3.0: usable with "Strategyzer.com" visible under
|
|
24
|
+
// it, which the canvas carries. The Value Proposition Canvas is NOT licensed for software, so the value proposition
|
|
25
|
+
// sheet is our own layout (the customer's outcome, motivation and gaps against the product's promise) and never uses
|
|
26
|
+
// its name or shape.
|
|
27
|
+
//
|
|
28
|
+
// Exit codes: 0 rendered, or nothing to render yet (said in one line) · 2 usage or could not write.
|
|
29
|
+
// Zero deps — Node 18+.
|
|
30
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { join, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
34
|
+
import { ONE_PAGERS_DIR, STRATEGY_DIR, parseFrontmatter, section } from './lib/strategy-files.mjs';
|
|
35
|
+
|
|
36
|
+
/** The persona block's fields, in the order the narrative template lists them under `### Persona`. */
|
|
37
|
+
export const PERSONA_FIELDS = [
|
|
38
|
+
'Role',
|
|
39
|
+
'Stage',
|
|
40
|
+
'Background',
|
|
41
|
+
'Goals',
|
|
42
|
+
'Frustrations',
|
|
43
|
+
'Drivers',
|
|
44
|
+
'Moments they reach for it',
|
|
45
|
+
'Where to find them',
|
|
46
|
+
'Words to use',
|
|
47
|
+
'Words to avoid',
|
|
48
|
+
'Not this persona',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const LABELS = ['true today', 'aspirational', 'agreed', 'sourced', 'hypothesis'];
|
|
52
|
+
export const NOT_WRITTEN = 'Not written yet';
|
|
53
|
+
export const BMC_CREDIT = 'The Business Model Canvas by Strategyzer.com, licensed CC BY-SA 3.0.';
|
|
54
|
+
|
|
55
|
+
/** Split a trailing `(label)` or `(sourced: <where>)` off a claim. An unknown or missing label is `null`. */
|
|
56
|
+
export function splitLabel(raw) {
|
|
57
|
+
const text = String(raw ?? '').trim();
|
|
58
|
+
const m = text.match(
|
|
59
|
+
// The source may hold a markdown link or a URL with its own parentheses; a trailing full stop is allowed.
|
|
60
|
+
/\s*\(\s*(true today|aspirational|agreed|hypothesis|sourced)(?:\s*:\s*((?:[^()]|\([^()]*\))*))?\s*\)\s*\.?\s*$/i
|
|
61
|
+
);
|
|
62
|
+
if (!m) return { text, label: null, source: null };
|
|
63
|
+
return { text: text.slice(0, m.index).trim(), label: m[1].toLowerCase(), source: m[2]?.trim() || null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A value the coach has not written: missing, or still the template's `<…>` placeholder. */
|
|
67
|
+
const unwritten = (v) => !v || /^<[^>]*>(?:\s*\([^)]*\))?$/.test(v.trim()); // a placeholder, maybe with its label hint
|
|
68
|
+
/** A value with any `<…>` placeholder fragment removed: half-filled template text never reaches a sheet. */
|
|
69
|
+
const fill = (v) =>
|
|
70
|
+
String(v ?? '')
|
|
71
|
+
.replace(/<[^>]*>/g, '')
|
|
72
|
+
.replace(/\s{2,}/g, ' ')
|
|
73
|
+
.trim();
|
|
74
|
+
|
|
75
|
+
/** `**Label:** value` lines inside `body`, by label. */
|
|
76
|
+
export function labelledLines(body) {
|
|
77
|
+
const out = {};
|
|
78
|
+
const lines = String(body ?? '').split('\n');
|
|
79
|
+
const LABEL = /^\s*(?:[-*]\s+)?\*\*([^*]+?):\*\*\s*(.*)$/;
|
|
80
|
+
for (let i = 0; i < lines.length; i++) {
|
|
81
|
+
const m = lines[i].match(LABEL);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
const parts = [m[2].trim()];
|
|
84
|
+
const items = [];
|
|
85
|
+
// A value may wrap onto following lines, or be a list of sub-bullets under an empty label: both are written
|
|
86
|
+
// content, and dropping them would print "Not written yet" over something the maker wrote (review of #316).
|
|
87
|
+
for (let k = i + 1; k < lines.length; k++) {
|
|
88
|
+
const next = lines[k];
|
|
89
|
+
if (!next.trim() || LABEL.test(next) || /^#{1,6} /.test(next)) break;
|
|
90
|
+
const bullet = next.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/);
|
|
91
|
+
if (bullet && (!parts[0] || /^\s/.test(next))) items.push(bullet[1].trim());
|
|
92
|
+
else if (!bullet && !items.length) parts.push(next.trim());
|
|
93
|
+
else break;
|
|
94
|
+
}
|
|
95
|
+
const raw = [parts.join(' ').trim(), ...items.filter((x) => !unwritten(x))].filter(
|
|
96
|
+
(x) => x && !unwritten(x)
|
|
97
|
+
);
|
|
98
|
+
const value = fill(raw.join(' · '));
|
|
99
|
+
if (!unwritten(value)) out[m[1].trim()] = value;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The `- ` bullets directly under a `**Label:**` line (Gaps:), or every top-level bullet when no label is given. */
|
|
105
|
+
export function bullets(body, after = null) {
|
|
106
|
+
const lines = String(body ?? '').split('\n');
|
|
107
|
+
let start = 0;
|
|
108
|
+
if (after) {
|
|
109
|
+
start = lines.findIndex((l) => new RegExp(`^\\s*\\*\\*${after}:\\*\\*`).test(l));
|
|
110
|
+
if (start === -1) return [];
|
|
111
|
+
start += 1;
|
|
112
|
+
}
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const line of lines.slice(start)) {
|
|
115
|
+
if (after && /^\s*\*\*[^*]+:\*\*/.test(line)) break;
|
|
116
|
+
const m = line.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/);
|
|
117
|
+
// `- **Fast:** drafts next week` is a benefit too: keep it, as "Fast: drafts next week".
|
|
118
|
+
// Placeholder check on the RAW text, before fill(): `<benefit> (true today | aspirational)` must not survive as
|
|
119
|
+
// a benefit called "(true today | aspirational)" (review of #316, round 2).
|
|
120
|
+
if (!m || unwritten(m[1])) continue;
|
|
121
|
+
const item = fill(m[1].replace(/^\*\*([^*]+?):\*\*\s*/, '$1: '));
|
|
122
|
+
if (item && !unwritten(item)) out.push(item);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The body of `### <heading>` inside an h2 section's text. */
|
|
128
|
+
function subsection(body, heading) {
|
|
129
|
+
const lines = String(body ?? '').split('\n');
|
|
130
|
+
const start = lines.findIndex((l) => l.trim() === `### ${heading}`);
|
|
131
|
+
if (start === -1) return null;
|
|
132
|
+
const end = lines.findIndex((l, i) => i > start && /^#{2,3} /.test(l));
|
|
133
|
+
return lines.slice(start + 1, end === -1 ? undefined : end).join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A claim with its label; a persona line without one is a hypothesis (F34). */
|
|
137
|
+
function claim(raw, { defaultLabel = null } = {}) {
|
|
138
|
+
const c = splitLabel(raw);
|
|
139
|
+
return { ...c, label: c.label ?? defaultLabel };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Pure — everything the three sheets show, read from the three files' text (any may be null). */
|
|
143
|
+
export function readStrategy({ narrative = null, northStar = null, risk = null } = {}) {
|
|
144
|
+
const n = narrative ?? '';
|
|
145
|
+
const fm = parseFrontmatter(n);
|
|
146
|
+
const problem = section(n, 'Problem to solve') ?? '';
|
|
147
|
+
const audience = section(n, 'Target audience') ?? '';
|
|
148
|
+
const vp = section(n, 'Value proposition') ?? '';
|
|
149
|
+
const adv = section(n, 'Competitive advantage') ?? '';
|
|
150
|
+
const growth = section(n, 'Growth strategy') ?? '';
|
|
151
|
+
const bm = section(n, 'Business model') ?? '';
|
|
152
|
+
const personaBody = subsection(audience, 'Persona');
|
|
153
|
+
const personaLines = labelledLines(personaBody);
|
|
154
|
+
const p = labelledLines(problem);
|
|
155
|
+
const a = labelledLines(audience);
|
|
156
|
+
const v = labelledLines(vp);
|
|
157
|
+
const c = labelledLines(adv);
|
|
158
|
+
const g = labelledLines(growth);
|
|
159
|
+
const b = labelledLines(bm);
|
|
160
|
+
|
|
161
|
+
const ns = northStar ?? '';
|
|
162
|
+
const metricLine = (section(ns, 'North Star metric') ?? '')
|
|
163
|
+
.split('\n')
|
|
164
|
+
.find((l) => l.trim() && !l.startsWith('_'));
|
|
165
|
+
const metric = metricLine?.match(/^\*\*([^*]+)\*\*\s*:?\s*(.*)$/);
|
|
166
|
+
|
|
167
|
+
const r = risk ?? '';
|
|
168
|
+
const domino = labelledLines(section(r, 'Highest domino'));
|
|
169
|
+
|
|
170
|
+
// A claim the coach left unlabelled still shows what it is (review of #316): the maker approved it at the Strategy
|
|
171
|
+
// gate when the file is agreed; until then it is unchecked. Persona lines stay hypothesis either way (F34).
|
|
172
|
+
const N = { defaultLabel: fm.status === 'agreed' ? 'agreed' : 'hypothesis' };
|
|
173
|
+
const benefits = bullets(vp).map((x) => claim(x, N));
|
|
174
|
+
return {
|
|
175
|
+
product: (n.match(/^# .*?—\s*(.+)$/m)?.[1] ?? '').replace(/^<.*>$/, '').trim() || null,
|
|
176
|
+
status: {
|
|
177
|
+
narrative: narrative === null ? null : fm.status || 'draft',
|
|
178
|
+
northStar: northStar === null ? null : parseFrontmatter(ns).status || 'draft',
|
|
179
|
+
risk: risk === null ? null : parseFrontmatter(r).status || 'draft',
|
|
180
|
+
},
|
|
181
|
+
persona:
|
|
182
|
+
personaBody === null
|
|
183
|
+
? null
|
|
184
|
+
: PERSONA_FIELDS.map((f) => ({
|
|
185
|
+
field: f,
|
|
186
|
+
...(personaLines[f]
|
|
187
|
+
? claim(personaLines[f], { defaultLabel: 'hypothesis' })
|
|
188
|
+
: { text: null, label: null }),
|
|
189
|
+
})),
|
|
190
|
+
outcome: p.Outcome ? claim(p.Outcome, N) : null,
|
|
191
|
+
motivation: p.Motivation ? claim(p.Motivation, N) : null,
|
|
192
|
+
gaps: bullets(problem, 'Gaps').map((x) => claim(x, N)),
|
|
193
|
+
segmentsNow: a.Now ? claim(a.Now, N) : null,
|
|
194
|
+
segmentsFuture: a.Future ? claim(a.Future, N) : null,
|
|
195
|
+
tagline: v.Tagline ? claim(v.Tagline, N) : null,
|
|
196
|
+
benefits,
|
|
197
|
+
shortTerm: c['Short term'] ? claim(c['Short term'], N) : null,
|
|
198
|
+
longTerm: c['Long term'] ? claim(c['Long term'], N) : null,
|
|
199
|
+
channelsNow: g.Now ? claim(g.Now, N) : null,
|
|
200
|
+
channelsLater: g.Later ? claim(g.Later, N) : null,
|
|
201
|
+
revenue: b.Revenue ? claim(b.Revenue, N) : null,
|
|
202
|
+
pricing: b.Pricing ? claim(b.Pricing, N) : null,
|
|
203
|
+
costs: b.Costs ? claim(b.Costs, N) : null,
|
|
204
|
+
partners: b['Key partners'] ? claim(b['Key partners'], N) : null,
|
|
205
|
+
activities: b['Key activities'] ? claim(b['Key activities'], N) : null,
|
|
206
|
+
resources: b['Key resources'] ? claim(b['Key resources'], N) : null,
|
|
207
|
+
relationships: b['Customer relationships'] ? claim(b['Customer relationships'], N) : null,
|
|
208
|
+
northStar:
|
|
209
|
+
metric && !unwritten(metric[1])
|
|
210
|
+
? { name: metric[1].replace(/:\s*$/, '').trim(), definition: metric[2].trim() }
|
|
211
|
+
: null,
|
|
212
|
+
notClaimedYet: [
|
|
213
|
+
...(domino.Hypothesis
|
|
214
|
+
? [
|
|
215
|
+
{
|
|
216
|
+
text: domino.Hypothesis,
|
|
217
|
+
label: 'hypothesis',
|
|
218
|
+
source: null,
|
|
219
|
+
why: `riskiest assumption${domino.Dimension ? ` (${domino.Dimension})` : ''}`,
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
223
|
+
...benefits
|
|
224
|
+
.filter((x) => x.label === 'aspirational')
|
|
225
|
+
.map((x) => ({ ...x, why: 'aspirational benefit' })),
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Rendering ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const esc = (s) =>
|
|
233
|
+
String(s).replace(
|
|
234
|
+
/[&<>"']/g,
|
|
235
|
+
(ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
function chipHtml(c) {
|
|
239
|
+
if (!c?.label) return '';
|
|
240
|
+
const cls = c.label === 'hypothesis' || c.label === 'aspirational' ? 'hyp' : 'ok';
|
|
241
|
+
const title = c.source ? ` title="${esc(c.source)}"` : '';
|
|
242
|
+
return ` <span class="chip ${cls}"${title}>${esc(c.label)}</span>`;
|
|
243
|
+
}
|
|
244
|
+
const chipMd = (c) => (c?.label ? ` _(${c.label}${c.source ? `: ${c.source}` : ''})_` : '');
|
|
245
|
+
|
|
246
|
+
const lineHtml = (c) =>
|
|
247
|
+
c?.text ? `${esc(c.text)}${chipHtml(c)}` : `<span class="muted">${NOT_WRITTEN}</span>`;
|
|
248
|
+
const lineMd = (c) => (c?.text ? `${c.text}${chipMd(c)}` : `_${NOT_WRITTEN}_`);
|
|
249
|
+
const listHtml = (cs) =>
|
|
250
|
+
cs.length
|
|
251
|
+
? `<ul>${cs.map((c) => `<li>${lineHtml(c)}</li>`).join('')}</ul>`
|
|
252
|
+
: `<p class="muted">${NOT_WRITTEN}</p>`;
|
|
253
|
+
const listMd = (cs) => (cs.length ? cs.map((c) => `- ${lineMd(c)}`).join('\n') : `_${NOT_WRITTEN}_`);
|
|
254
|
+
|
|
255
|
+
const CSS = `:root{--bg:#f3f3ef;--paper:#fff;--ink:#1a1916;--muted:#66625a;--line:#d8d5cc;--gold:#9c7000;--gold-bg:#fbf1d3;
|
|
256
|
+
--hyp:#7a4a00;--hyp-bg:#fff6e0;--ok:#2b6b3a;--ok-bg:#e3f2e6;color-scheme:light}
|
|
257
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#11100e;--paper:#1a1916;--ink:#ecebe5;--muted:#a29e94;
|
|
258
|
+
--line:#2f2d29;--gold:#e5b53d;--gold-bg:rgba(229,181,61,.14);--hyp:#f0b35a;--hyp-bg:rgba(240,179,90,.12);--ok:#86d196;--ok-bg:rgba(134,209,150,.12);color-scheme:dark}}
|
|
259
|
+
:root[data-theme="dark"]{--bg:#11100e;--paper:#1a1916;--ink:#ecebe5;--muted:#a29e94;--line:#2f2d29;--gold:#e5b53d;
|
|
260
|
+
--gold-bg:rgba(229,181,61,.14);--hyp:#f0b35a;--hyp-bg:rgba(240,179,90,.12);--ok:#86d196;--ok-bg:rgba(134,209,150,.12);color-scheme:dark}
|
|
261
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px 16px}
|
|
262
|
+
.sheet{position:relative;max-width:1240px;margin:0 auto;background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:24px 26px;overflow:hidden}
|
|
263
|
+
header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 24px;border-bottom:2px solid var(--ink);padding-bottom:10px;margin-bottom:16px}
|
|
264
|
+
h1{font:800 28px/1.1 system-ui,sans-serif;margin:0}.kicker{display:block;font:600 11px system-ui;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
|
|
265
|
+
h2{font:700 12px system-ui;text-transform:uppercase;letter-spacing:.1em;margin:0 0 8px}
|
|
266
|
+
ul{margin:0;padding-left:16px}li{margin:0 0 5px}p{margin:0 0 6px}.muted{color:var(--muted)}
|
|
267
|
+
.chip{display:inline-block;font:600 10.5px system-ui;letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:99px;vertical-align:1px}
|
|
268
|
+
.chip.ok{background:var(--ok-bg);color:var(--ok)}.chip.hyp{background:var(--hyp-bg);color:var(--hyp);border:1px dashed var(--hyp)}
|
|
269
|
+
.grid{display:grid;gap:12px}.box{border:1px solid var(--line);border-radius:6px;padding:12px 14px}
|
|
270
|
+
.bmc{grid-template-columns:repeat(5,1fr);grid-template-rows:auto auto auto}
|
|
271
|
+
.bmc .kp{grid-row:1/3}.bmc .vp{grid-row:1/3;grid-column:3}.bmc .cs{grid-row:1/3;grid-column:5}
|
|
272
|
+
.bmc .ka{grid-column:2}.bmc .kr{grid-column:2;grid-row:2}.bmc .cr{grid-column:4}.bmc .ch{grid-column:4;grid-row:2}
|
|
273
|
+
.bmc .co{grid-column:1/3;grid-row:3}.bmc .rs{grid-column:3/6;grid-row:3}
|
|
274
|
+
.two{grid-template-columns:1fr 1fr}.persona{grid-template-columns:repeat(3,1fr)}
|
|
275
|
+
.lead{font-size:18px;margin:0 0 12px}.ns{border:2px solid var(--gold);background:var(--gold-bg)}.ns b{color:var(--gold);font-size:18px;display:block}
|
|
276
|
+
footer{margin-top:16px;padding-top:8px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted);display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 18px}
|
|
277
|
+
.draft{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;font:900 140px system-ui;color:var(--hyp);opacity:.09;transform:rotate(-18deg)}
|
|
278
|
+
@media screen and (max-width:820px){.bmc,.two,.persona{grid-template-columns:1fr}.bmc>*{grid-column:auto!important;grid-row:auto!important}}
|
|
279
|
+
@media print{body{background:#fff;padding:0}.sheet{border:0;max-width:none;padding:8mm}@page{size:A4 landscape;margin:8mm}}`;
|
|
280
|
+
|
|
281
|
+
function page({ title, kicker, body, draft, sources, footer = '' }) {
|
|
282
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
283
|
+
<title>${esc(title)}</title><style>${CSS}</style></head><body><main class="sheet">${draft ? '<div class="draft" aria-hidden="true">Draft</div>' : ''}
|
|
284
|
+
<header><div><span class="kicker">${esc(kicker)}${draft ? ' · draft' : ''}</span><h1>${esc(title)}</h1></div>
|
|
285
|
+
<div class="muted">From ${esc(sources)}.</div></header>
|
|
286
|
+
${body}
|
|
287
|
+
<footer>${footer}<span>Rendered from the strategy files by one-pagers.mjs. Never edit this sheet; edit the file and render again.</span></footer></main></body></html>
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const isDraft = (...statuses) => statuses.some((s) => s && s !== 'agreed');
|
|
292
|
+
const named = (s, kind) => `${s.product ? `${s.product}: ` : ''}${kind}`;
|
|
293
|
+
|
|
294
|
+
/** Pure — the business model canvas, as { html, md }. */
|
|
295
|
+
export function renderCanvas(s) {
|
|
296
|
+
const box = (cls, title, inner) => `<section class="box ${cls}"><h2>${title}</h2>${inner}</section>`;
|
|
297
|
+
const one = (c) => `<p>${lineHtml(c)}</p>`;
|
|
298
|
+
const segments = [
|
|
299
|
+
s.segmentsNow && { ...s.segmentsNow, text: `Now: ${s.segmentsNow.text}` },
|
|
300
|
+
s.segmentsFuture && { ...s.segmentsFuture, text: `Later: ${s.segmentsFuture.text}` },
|
|
301
|
+
].filter(Boolean);
|
|
302
|
+
const channels = [
|
|
303
|
+
s.channelsNow && { ...s.channelsNow, text: `Now: ${s.channelsNow.text}` },
|
|
304
|
+
s.channelsLater && { ...s.channelsLater, text: `Later: ${s.channelsLater.text}` },
|
|
305
|
+
].filter(Boolean);
|
|
306
|
+
const value = [s.tagline, ...s.benefits].filter(Boolean);
|
|
307
|
+
const revenue = [s.revenue, s.pricing].filter(Boolean);
|
|
308
|
+
const draft = isDraft(s.status.narrative);
|
|
309
|
+
const html = page({
|
|
310
|
+
title: named(s, 'Business model canvas'),
|
|
311
|
+
kicker: 'Strategy one-pager',
|
|
312
|
+
draft,
|
|
313
|
+
sources: 'pmf-narrative.md',
|
|
314
|
+
body: `<div class="grid bmc">${box('kp', 'Key partners', one(s.partners))}${box('ka', 'Key activities', one(s.activities))}${box('kr', 'Key resources', one(s.resources))}${box('vp', 'Value propositions', listHtml(value))}${box('cr', 'Customer relationships', one(s.relationships))}${box('ch', 'Channels', listHtml(channels))}${box('cs', 'Customer segments', listHtml(segments))}${box('co', 'Cost structure', one(s.costs))}${box('rs', 'Revenue streams', listHtml(revenue))}</div>`,
|
|
315
|
+
footer: `<span>${esc(BMC_CREDIT)}</span>`,
|
|
316
|
+
});
|
|
317
|
+
const md = [
|
|
318
|
+
`# ${named(s, 'Business model canvas')}${draft ? ' (draft)' : ''}`,
|
|
319
|
+
'',
|
|
320
|
+
...[
|
|
321
|
+
['Key partners', lineMd(s.partners)],
|
|
322
|
+
['Key activities', lineMd(s.activities)],
|
|
323
|
+
['Key resources', lineMd(s.resources)],
|
|
324
|
+
['Value propositions', listMd(value)],
|
|
325
|
+
['Customer relationships', lineMd(s.relationships)],
|
|
326
|
+
['Channels', listMd(channels)],
|
|
327
|
+
['Customer segments', listMd(segments)],
|
|
328
|
+
['Cost structure', lineMd(s.costs)],
|
|
329
|
+
['Revenue streams', listMd(revenue)],
|
|
330
|
+
].flatMap(([h, v]) => [`## ${h}`, '', v, '']),
|
|
331
|
+
`_${BMC_CREDIT}_`,
|
|
332
|
+
'',
|
|
333
|
+
].join('\n');
|
|
334
|
+
return { html, md };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Pure — the value proposition sheet (our own layout, never the reserved canvas), as { html, md }. */
|
|
338
|
+
export function renderValueSheet(s) {
|
|
339
|
+
const draft = isDraft(s.status.narrative, s.status.northStar, s.status.risk);
|
|
340
|
+
const ns = s.northStar
|
|
341
|
+
? `<section class="box ns"><h2>North Star</h2><b>${esc(s.northStar.name)}</b><p>${esc(s.northStar.definition)}</p></section>`
|
|
342
|
+
: `<section class="box"><h2>North Star</h2><p class="muted">${NOT_WRITTEN}</p></section>`;
|
|
343
|
+
const html = page({
|
|
344
|
+
title: named(s, 'Value proposition sheet'),
|
|
345
|
+
kicker: 'Strategy one-pager',
|
|
346
|
+
draft,
|
|
347
|
+
sources: 'pmf-narrative.md · north-star.md · risk-validation.md',
|
|
348
|
+
body: `<p class="lead"><strong>${lineHtml(s.tagline)}</strong></p>
|
|
349
|
+
<div class="grid two"><section class="box"><h2>What the customer is after</h2><p><strong>Outcome:</strong> ${lineHtml(s.outcome)}</p><p><strong>Why:</strong> ${lineHtml(s.motivation)}</p><h2>What stands in the way</h2>${listHtml(s.gaps)}</section>
|
|
350
|
+
<section class="box"><h2>What the product promises</h2>${listHtml(s.benefits)}<h2>Why it wins</h2><p><strong>Today:</strong> ${lineHtml(s.shortTerm)}</p><p><strong>Over time:</strong> ${lineHtml(s.longTerm)}</p></section></div>
|
|
351
|
+
<div class="grid two" style="margin-top:12px">${ns}<section class="box"><h2>Not claimed yet</h2>${s.notClaimedYet.length ? `<ul>${s.notClaimedYet.map((c) => `<li>${esc(c.text)}${chipHtml(c)} <span class="muted">(${esc(c.why)})</span></li>`).join('')}</ul>` : '<p class="muted">Nothing marked as untested.</p>'}</section></div>`,
|
|
352
|
+
});
|
|
353
|
+
const md = [
|
|
354
|
+
`# ${named(s, 'Value proposition sheet')}${draft ? ' (draft)' : ''}`,
|
|
355
|
+
'',
|
|
356
|
+
`**${lineMd(s.tagline)}**`,
|
|
357
|
+
'',
|
|
358
|
+
'## What the customer is after',
|
|
359
|
+
'',
|
|
360
|
+
`- **Outcome:** ${lineMd(s.outcome)}`,
|
|
361
|
+
`- **Why:** ${lineMd(s.motivation)}`,
|
|
362
|
+
'',
|
|
363
|
+
'## What stands in the way',
|
|
364
|
+
'',
|
|
365
|
+
listMd(s.gaps),
|
|
366
|
+
'',
|
|
367
|
+
'## What the product promises',
|
|
368
|
+
'',
|
|
369
|
+
listMd(s.benefits),
|
|
370
|
+
'',
|
|
371
|
+
'## Why it wins',
|
|
372
|
+
'',
|
|
373
|
+
`- **Today:** ${lineMd(s.shortTerm)}`,
|
|
374
|
+
`- **Over time:** ${lineMd(s.longTerm)}`,
|
|
375
|
+
'',
|
|
376
|
+
'## North Star',
|
|
377
|
+
'',
|
|
378
|
+
s.northStar ? `**${s.northStar.name}**: ${s.northStar.definition}` : `_${NOT_WRITTEN}_`,
|
|
379
|
+
'',
|
|
380
|
+
'## Not claimed yet',
|
|
381
|
+
'',
|
|
382
|
+
s.notClaimedYet.length
|
|
383
|
+
? s.notClaimedYet.map((c) => `- ${c.text}${chipMd(c)} (${c.why})`).join('\n')
|
|
384
|
+
: '_Nothing marked as untested._',
|
|
385
|
+
'',
|
|
386
|
+
].join('\n');
|
|
387
|
+
return { html, md };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Pure — the persona poster, as { html, md }. Every line carries a label; an unlabelled one reads hypothesis. */
|
|
391
|
+
export function renderPersona(s) {
|
|
392
|
+
const draft = isDraft(s.status.narrative);
|
|
393
|
+
const rows = s.persona ?? PERSONA_FIELDS.map((field) => ({ field, text: null, label: null }));
|
|
394
|
+
const role = rows.find((r) => r.field === 'Role');
|
|
395
|
+
const html = page({
|
|
396
|
+
title: named(s, `Persona${role?.text ? `: ${role.text}` : ''}`),
|
|
397
|
+
kicker: 'Strategy one-pager',
|
|
398
|
+
draft,
|
|
399
|
+
sources: 'pmf-narrative.md (Target audience → Persona)',
|
|
400
|
+
body: `<div class="grid persona">${rows
|
|
401
|
+
.map((r) => `<section class="box"><h2>${esc(r.field)}</h2><p>${lineHtml(r)}</p></section>`)
|
|
402
|
+
.join('')}</div>`,
|
|
403
|
+
footer:
|
|
404
|
+
'<span><span class="chip ok">sourced</span> / <span class="chip ok">agreed</span> = checked or confirmed · <span class="chip hyp">hypothesis</span> = not checked yet</span>',
|
|
405
|
+
});
|
|
406
|
+
const md = [
|
|
407
|
+
`# ${named(s, `Persona${role?.text ? `: ${role.text}` : ''}`)}${draft ? ' (draft)' : ''}`,
|
|
408
|
+
'',
|
|
409
|
+
...rows.flatMap((r) => [`## ${r.field}`, '', lineMd(r), '']),
|
|
410
|
+
].join('\n');
|
|
411
|
+
return { html, md };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const SHEETS = {
|
|
415
|
+
'business-model-canvas': renderCanvas,
|
|
416
|
+
'value-proposition-sheet': renderValueSheet,
|
|
417
|
+
'persona-poster': renderPersona,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
function readOrNull(path) {
|
|
421
|
+
try {
|
|
422
|
+
return readFileSync(path, 'utf8');
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function main(argv) {
|
|
429
|
+
const root = projectRoot();
|
|
430
|
+
const i = argv.indexOf('--out');
|
|
431
|
+
if (i !== -1 && !argv[i + 1]) {
|
|
432
|
+
process.stderr.write('usage: one-pagers.mjs [--out <dir>]\n');
|
|
433
|
+
return 2;
|
|
434
|
+
}
|
|
435
|
+
const out = i !== -1 ? resolve(argv[i + 1]) : join(root, ONE_PAGERS_DIR);
|
|
436
|
+
const file = (k) => readOrNull(join(root, STRATEGY_DIR, `${k}.md`));
|
|
437
|
+
const narrative = file('pmf-narrative');
|
|
438
|
+
if (narrative === null) {
|
|
439
|
+
process.stdout.write(
|
|
440
|
+
`one-pagers: nothing to render yet — the sheets come from ${STRATEGY_DIR}/pmf-narrative.md, which doesn't exist.\n`
|
|
441
|
+
);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
const s = readStrategy({ narrative, northStar: file('north-star'), risk: file('risk-validation') });
|
|
445
|
+
try {
|
|
446
|
+
mkdirSync(out, { recursive: true });
|
|
447
|
+
const written = [];
|
|
448
|
+
for (const [name, render] of Object.entries(SHEETS)) {
|
|
449
|
+
const { html, md } = render(s);
|
|
450
|
+
writeFileSync(join(out, `${name}.html`), html);
|
|
451
|
+
writeFileSync(join(out, `${name}.md`), md);
|
|
452
|
+
written.push(`${name}.html`);
|
|
453
|
+
}
|
|
454
|
+
const draft = isDraft(s.status.narrative, s.status.northStar, s.status.risk);
|
|
455
|
+
process.stdout.write(
|
|
456
|
+
`one-pagers: wrote ${written.join(', ')} (+ .md) to ${out}${draft ? ' — marked draft until the Strategy gate approves the files' : ''}.\n`
|
|
457
|
+
);
|
|
458
|
+
return 0;
|
|
459
|
+
} catch (e) {
|
|
460
|
+
process.stderr.write(`one-pagers: could not write to ${out}: ${e.message}\n`);
|
|
461
|
+
return 2;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
|
|
466
|
+
process.exitCode = main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|