atris 3.17.0 → 3.23.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/atris.md +3 -3
- package/bin/atris.js +20 -2
- package/commands/card.js +121 -0
- package/commands/deck.js +77 -28
- package/commands/recap.js +16 -0
- package/commands/site.js +48 -0
- package/commands/slop.js +146 -12
- package/commands/theme.js +217 -0
- package/lib/card.js +120 -0
- package/lib/deck-from-md.js +110 -0
- package/lib/html-render.js +257 -0
- package/lib/memory-view.js +95 -0
- package/lib/site.js +114 -0
- package/lib/slides-deck.js +3 -2
- package/lib/theme.js +264 -0
- package/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// atris theme — brand themes for the whole design system. Define your colors and
|
|
2
|
+
// fonts once in .atris/theme.json; every deck, HTML page, and site uses them.
|
|
3
|
+
//
|
|
4
|
+
// atris theme create guided interview -> your own theme (alias: new)
|
|
5
|
+
// atris theme edit <name> re-run the interview to tweak an existing theme
|
|
6
|
+
// atris theme init scaffold a starter .atris/theme.json
|
|
7
|
+
// atris theme list list built-in + project themes
|
|
8
|
+
// atris theme show <name> print a resolved theme
|
|
9
|
+
|
|
10
|
+
const readline = require('readline');
|
|
11
|
+
const {
|
|
12
|
+
mergedThemes, writeStarterTheme, loadProjectThemes, loadThemeRecipe, PROJECT_THEME_FILE,
|
|
13
|
+
MOODS, MOOD_NAMES, FONT_PERSONALITIES, buildTheme, themeWarnings, upsertProjectTheme,
|
|
14
|
+
resolveMode, isHex, normHex, hexToRgb,
|
|
15
|
+
} = require('../lib/theme');
|
|
16
|
+
const { THEMES: HTML_THEMES } = require('../lib/html-render');
|
|
17
|
+
|
|
18
|
+
// ---------- small terminal helpers ----------
|
|
19
|
+
function parseFlags(argv) {
|
|
20
|
+
const flags = {}; const pos = [];
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === '-y' || a === '--yes') { flags.yes = true; continue; }
|
|
24
|
+
if (a.startsWith('--')) {
|
|
25
|
+
const key = a.slice(2);
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next != null && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
28
|
+
else flags[key] = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
pos.push(a);
|
|
32
|
+
}
|
|
33
|
+
return { flags, pos };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// a colored block when we're on a real terminal, two blank cells otherwise (clean pipes)
|
|
37
|
+
function sw(hex) {
|
|
38
|
+
const c = hexToRgb(hex);
|
|
39
|
+
if (!c || !process.stdout.isTTY) return ' ';
|
|
40
|
+
return `\x1b[48;2;${c.r};${c.g};${c.b}m \x1b[0m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printPreview(name, answers, theme) {
|
|
44
|
+
const c = theme.color;
|
|
45
|
+
const mode = resolveMode(answers.mood || 'editorial', answers.mode);
|
|
46
|
+
console.log(`\n ${name} · ${answers.mood || 'editorial'} ${mode}`);
|
|
47
|
+
const row = (label, hex) => console.log(` ${sw(hex)} ${label.padEnd(8)} ${hex}`);
|
|
48
|
+
row('bg', c.bg);
|
|
49
|
+
row('ink', c.ink);
|
|
50
|
+
row('accent', c.accent);
|
|
51
|
+
row('accent2', c.accent2);
|
|
52
|
+
console.log(` ${theme.fonts.display} display, ${theme.fonts.body} body`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- the guided interview (tasteful vocabulary) ----------
|
|
56
|
+
async function interview(defaults = {}) {
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
58
|
+
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
59
|
+
const ans = {};
|
|
60
|
+
try {
|
|
61
|
+
console.log('\n let\'s make your theme. five quick choices, all changeable later.\n');
|
|
62
|
+
|
|
63
|
+
const dn = defaults.name || 'brand';
|
|
64
|
+
ans.name = (await ask(` 1/5 name it [${dn}]: `)).trim() || dn;
|
|
65
|
+
|
|
66
|
+
console.log('\n 2/5 what should it feel like?');
|
|
67
|
+
MOOD_NAMES.forEach((m, i) => console.log(` ${i + 1}. ${m.padEnd(10)} ${MOODS[m].blurb}`));
|
|
68
|
+
const dmIdx = Math.max(0, MOOD_NAMES.indexOf(defaults.mood || 'editorial'));
|
|
69
|
+
const moodPick = (await ask(` pick 1-${MOOD_NAMES.length} [${dmIdx + 1}]: `)).trim();
|
|
70
|
+
ans.mood = MOOD_NAMES[(parseInt(moodPick, 10) - 1)] || MOOD_NAMES[dmIdx];
|
|
71
|
+
const mood = MOODS[ans.mood];
|
|
72
|
+
|
|
73
|
+
if (mood.forceMode) {
|
|
74
|
+
ans.mode = mood.forceMode;
|
|
75
|
+
console.log(` (${ans.mood} is ${mood.forceMode}-only)`);
|
|
76
|
+
} else {
|
|
77
|
+
const dmode = defaults.mode || mood.defaultMode;
|
|
78
|
+
const mp = (await ask(`\n 3/5 light or dark? [${dmode[0]}]: `)).trim().toLowerCase();
|
|
79
|
+
ans.mode = mp.startsWith('l') ? 'light' : mp.startsWith('d') ? 'dark' : dmode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('\n 4/5 your accent — the one color that is unmistakably yours:');
|
|
83
|
+
mood.swatches.forEach((s, i) => console.log(` ${i + 1}. ${sw(s)} ${s}`));
|
|
84
|
+
const dac = normHex(defaults.accent) || mood.swatches[0];
|
|
85
|
+
const ap = (await ask(` pick 1-${mood.swatches.length}, or paste a hex [${dac}]: `)).trim();
|
|
86
|
+
if (!ap) ans.accent = dac;
|
|
87
|
+
else if (/^[1-9]$/.test(ap) && mood.swatches[parseInt(ap, 10) - 1]) ans.accent = mood.swatches[parseInt(ap, 10) - 1];
|
|
88
|
+
else if (isHex(ap)) ans.accent = normHex(ap);
|
|
89
|
+
else { console.log(` '${ap}' is not 1-${mood.swatches.length} or a hex, keeping ${dac}`); ans.accent = dac; }
|
|
90
|
+
|
|
91
|
+
console.log('\n 5/5 type personality:');
|
|
92
|
+
const fontKeys = Object.keys(FONT_PERSONALITIES);
|
|
93
|
+
fontKeys.forEach((k, i) => console.log(` ${i + 1}. ${k.padEnd(15)} ${FONT_PERSONALITIES[k].display} / ${FONT_PERSONALITIES[k].body}`));
|
|
94
|
+
const dfIdx = fontKeys.indexOf(typeof defaults.fonts === 'string' ? defaults.fonts : mood.fonts);
|
|
95
|
+
const fp = (await ask(` pick 1-${fontKeys.length} [${(dfIdx < 0 ? 0 : dfIdx) + 1}]: `)).trim();
|
|
96
|
+
ans.fonts = fontKeys[(parseInt(fp, 10) - 1)] || fontKeys[dfIdx < 0 ? 0 : dfIdx];
|
|
97
|
+
} finally {
|
|
98
|
+
rl.close();
|
|
99
|
+
}
|
|
100
|
+
return ans;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function confirmSync(q) {
|
|
104
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
105
|
+
return new Promise((res) => rl.question(q, (a) => { rl.close(); res(!String(a).trim().toLowerCase().startsWith('n')); }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// shared create/edit flow. defaults seed an edit; flags override anything; interactive
|
|
109
|
+
// only when there's a TTY and no defining flags.
|
|
110
|
+
async function createFlow({ name, defaults = {}, flags }) {
|
|
111
|
+
const answers = { ...defaults };
|
|
112
|
+
if (flags.mood) answers.mood = flags.mood;
|
|
113
|
+
if (flags.mode) answers.mode = flags.mode;
|
|
114
|
+
if (flags.accent) answers.accent = flags.accent;
|
|
115
|
+
if (flags.accent2) answers.accent2 = flags.accent2;
|
|
116
|
+
if (flags.fonts) answers.fonts = flags.fonts;
|
|
117
|
+
let themeName = name || flags.name || defaults.name || 'brand';
|
|
118
|
+
|
|
119
|
+
const hasDefiningFlag = Boolean(flags.mood || flags.mode || flags.accent || flags.accent2 || flags.fonts || flags.name);
|
|
120
|
+
const interactive = Boolean(process.stdin.isTTY) && !flags.yes && !hasDefiningFlag;
|
|
121
|
+
|
|
122
|
+
if (interactive) {
|
|
123
|
+
const r = await interview({ name: themeName, ...answers });
|
|
124
|
+
Object.assign(answers, r);
|
|
125
|
+
themeName = r.name || themeName;
|
|
126
|
+
}
|
|
127
|
+
themeName = String(themeName).trim() || 'brand';
|
|
128
|
+
|
|
129
|
+
if (answers.mood && !MOODS[answers.mood]) { console.error(` unknown mood "${answers.mood}". try: ${MOOD_NAMES.join(', ')}`); return 2; }
|
|
130
|
+
if (answers.accent && !isHex(answers.accent)) { console.error(` not a hex color: ${answers.accent}`); return 2; }
|
|
131
|
+
if (answers.accent2 && !isHex(answers.accent2)) { console.error(` not a hex color: ${answers.accent2}`); return 2; }
|
|
132
|
+
if (answers.fonts && typeof answers.fonts === 'string' && !FONT_PERSONALITIES[answers.fonts]) {
|
|
133
|
+
console.error(` unknown font personality "${answers.fonts}". try: ${Object.keys(FONT_PERSONALITIES).join(', ')}`); return 2;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const theme = buildTheme(answers);
|
|
137
|
+
printPreview(themeName, answers, theme);
|
|
138
|
+
const warns = themeWarnings(theme);
|
|
139
|
+
if (warns.length) { console.log('\n heads up:'); warns.forEach((w) => console.log(` ! ${w}`)); }
|
|
140
|
+
|
|
141
|
+
if (interactive) {
|
|
142
|
+
const ok = await confirmSync('\n save this theme? [enter = yes, n = cancel] ');
|
|
143
|
+
if (!ok) { console.log(' cancelled, nothing written.\n'); return 0; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const recipe = {
|
|
147
|
+
mood: answers.mood || 'editorial',
|
|
148
|
+
mode: resolveMode(answers.mood || 'editorial', answers.mode),
|
|
149
|
+
accent: theme.color.accent,
|
|
150
|
+
accent2: theme.color.accent2,
|
|
151
|
+
fonts: typeof answers.fonts === 'string' ? answers.fonts : (MOODS[answers.mood || 'editorial'].fonts),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let res;
|
|
155
|
+
try { res = upsertProjectTheme(themeName, theme, process.cwd(), recipe); }
|
|
156
|
+
catch (e) { console.error(` ${e.message}`); return 1; }
|
|
157
|
+
|
|
158
|
+
console.log(`\n ✓ ${res.action} theme "${res.name}" in ${PROJECT_THEME_FILE} (${res.count} theme${res.count === 1 ? '' : 's'})`);
|
|
159
|
+
console.log(' use it anywhere:');
|
|
160
|
+
console.log(` atris deck from doc.md --html --theme ${res.name}`);
|
|
161
|
+
console.log(` atris site ./docs --theme ${res.name}`);
|
|
162
|
+
console.log(` tweak it later: atris theme edit ${res.name}\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function run(argv) {
|
|
167
|
+
const sub = argv[0];
|
|
168
|
+
const rest = argv.slice(1);
|
|
169
|
+
|
|
170
|
+
if (sub === 'create' || sub === 'new') {
|
|
171
|
+
const { flags, pos } = parseFlags(rest);
|
|
172
|
+
return createFlow({ name: pos[0], flags });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sub === 'edit') {
|
|
176
|
+
const { flags, pos } = parseFlags(rest);
|
|
177
|
+
const name = pos[0] || flags.name;
|
|
178
|
+
if (!name) { console.error(' usage: atris theme edit <name>'); return 2; }
|
|
179
|
+
const existing = loadProjectThemes()[name];
|
|
180
|
+
if (!existing) { console.error(` no project theme "${name}". make one with: atris theme create ${name}`); return 2; }
|
|
181
|
+
// prefer the saved recipe; otherwise seed from the resolved colors/fonts
|
|
182
|
+
const recipe = loadThemeRecipe(name);
|
|
183
|
+
const defaults = recipe || { accent: existing.color.accent, accent2: existing.color.accent2, fonts: existing.fonts };
|
|
184
|
+
defaults.name = name;
|
|
185
|
+
return createFlow({ name, defaults, flags });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (sub === 'init') {
|
|
189
|
+
const { file, already } = writeStarterTheme();
|
|
190
|
+
console.log(already
|
|
191
|
+
? `\n already exists: ${file}\n customize it with: atris theme edit brand\n`
|
|
192
|
+
: `\n ✓ brand theme scaffolded: ${file}\n or build one by feel: atris theme create\n`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (sub === 'show') {
|
|
197
|
+
const name = rest[0];
|
|
198
|
+
const t = mergedThemes(HTML_THEMES)[name];
|
|
199
|
+
if (!t) { console.error(` unknown theme "${name}"`); return 2; }
|
|
200
|
+
console.log(JSON.stringify(t, null, 2));
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// list (default)
|
|
205
|
+
const project = loadProjectThemes();
|
|
206
|
+
const all = mergedThemes(HTML_THEMES);
|
|
207
|
+
console.log('\n atris themes:\n');
|
|
208
|
+
for (const name of Object.keys(all)) {
|
|
209
|
+
const tag = project[name] ? 'project' : 'built-in';
|
|
210
|
+
console.log(` ${sw(all[name].color.accent)} ${name.padEnd(12)} accent ${all[name].color.accent} bg ${all[name].color.bg} (${tag})`);
|
|
211
|
+
}
|
|
212
|
+
console.log(`\n make your own by feel: atris theme create`);
|
|
213
|
+
console.log(` themes live in ${PROJECT_THEME_FILE}. Used by deck, html, and site.\n`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { run };
|
package/lib/card.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// atris card — turn one line of text into a beautiful, on-brand image.
|
|
2
|
+
// Pure: spec -> self-contained HTML string. Reuses the design system: the same
|
|
3
|
+
// themes as deck/site/html (incl. your .atris/theme.json brand), one accent,
|
|
4
|
+
// Fraunces display, restraint. Render to PNG with headless Chrome (commands/card.js).
|
|
5
|
+
//
|
|
6
|
+
// Kinds: statement (kicker + headline + sub), quote (quote + attribution),
|
|
7
|
+
// stat (big number + label). Sizes: og, wide, square, story.
|
|
8
|
+
|
|
9
|
+
const { mergedThemes } = require('./theme');
|
|
10
|
+
const { THEMES: HTML_THEMES, rich, esc } = require('./html-render');
|
|
11
|
+
|
|
12
|
+
const SIZES = {
|
|
13
|
+
og: { w: 1200, h: 630 }, // OpenGraph / link card
|
|
14
|
+
wide: { w: 1920, h: 1080 }, // slide / banner
|
|
15
|
+
square: { w: 1080, h: 1080 }, // feed
|
|
16
|
+
story: { w: 1080, h: 1920 }, // story / reel
|
|
17
|
+
};
|
|
18
|
+
const KINDS = ['statement', 'quote', 'stat'];
|
|
19
|
+
|
|
20
|
+
const plainLen = (s) => String(s == null ? '' : s).replace(/\*\*/g, '').length;
|
|
21
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
22
|
+
|
|
23
|
+
// shrink a display line so long text still fits the card
|
|
24
|
+
function fit(base, text, refChars) {
|
|
25
|
+
return Math.round(base * clamp(refChars / Math.max(plainLen(text), 1), 0.5, 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fontStack(name, fallback) {
|
|
29
|
+
return `'${String(name || fallback).replace(/'/g, '')}', '${fallback}', ${fallback === 'Fraunces' ? 'Georgia, serif' : 'system-ui, sans-serif'}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildCard(spec = {}, opts = {}) {
|
|
33
|
+
const size = SIZES[spec.size] || SIZES.og;
|
|
34
|
+
const themes = opts.themes || mergedThemes(HTML_THEMES, opts.root || process.cwd());
|
|
35
|
+
const theme = themes[spec.theme] || HTML_THEMES.atris;
|
|
36
|
+
const c = theme.color;
|
|
37
|
+
const f = theme.fonts || {};
|
|
38
|
+
const kind = KINDS.includes(spec.kind) ? spec.kind : 'statement';
|
|
39
|
+
const u = Math.min(size.w, size.h);
|
|
40
|
+
|
|
41
|
+
const pad = Math.round(u * 0.088);
|
|
42
|
+
const px = {
|
|
43
|
+
kicker: Math.round(u * 0.030),
|
|
44
|
+
sub: Math.round(u * 0.040),
|
|
45
|
+
foot: Math.round(u * 0.028),
|
|
46
|
+
rule: Math.round(u * 0.010),
|
|
47
|
+
gap: Math.round(u * 0.030),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const brand = spec.brand || 'Atris';
|
|
51
|
+
const version = spec.version || '';
|
|
52
|
+
const display = fontStack(f.display, 'Fraunces');
|
|
53
|
+
const body = fontStack(f.body, 'Outfit');
|
|
54
|
+
const grotesk = "'Space Grotesk', system-ui, sans-serif";
|
|
55
|
+
const monoFont = "'IBM Plex Mono', ui-monospace, monospace";
|
|
56
|
+
|
|
57
|
+
const foot = `<div class="foot"><span class="mark">${esc(brand)}</span>${version ? `<span class="ver">${esc(version)}</span>` : ''}</div>`;
|
|
58
|
+
const kickerHtml = spec.kicker ? `<div class="kicker">${rich(spec.kicker)}</div>` : '';
|
|
59
|
+
const subHtml = spec.sub ? `<p class="sub">${rich(spec.sub)}</p>` : '';
|
|
60
|
+
|
|
61
|
+
let main = '';
|
|
62
|
+
let extraCss = '';
|
|
63
|
+
if (kind === 'quote') {
|
|
64
|
+
const text = spec.text || spec.headline || 'Your quote here';
|
|
65
|
+
const qSize = fit(Math.round(u * 0.090), text, 64);
|
|
66
|
+
main = `<div class="rule"></div>
|
|
67
|
+
<div class="qmark">“</div>
|
|
68
|
+
<blockquote class="qtext">${rich(text)}</blockquote>
|
|
69
|
+
${spec.by ? `<div class="by">${rich(spec.by)}</div>` : ''}`;
|
|
70
|
+
extraCss = `
|
|
71
|
+
.qmark{font-family:${display};color:${c.accent};font-size:${Math.round(u * 0.20)}px;line-height:.6;height:${Math.round(u * 0.10)}px}
|
|
72
|
+
.qtext{font-family:${display};font-weight:600;font-size:${qSize}px;line-height:1.08;letter-spacing:-1px;margin:${px.gap}px 0}
|
|
73
|
+
.by{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft}}`;
|
|
74
|
+
} else if (kind === 'stat') {
|
|
75
|
+
const number = spec.number || spec.headline || '42';
|
|
76
|
+
const numSize = fit(Math.round(u * 0.34), number, 6);
|
|
77
|
+
main = `<div class="rule"></div>
|
|
78
|
+
${kickerHtml}
|
|
79
|
+
<div class="big">${rich(number)}</div>
|
|
80
|
+
${spec.label ? `<div class="statlabel">${rich(spec.label)}</div>` : ''}
|
|
81
|
+
${subHtml}`;
|
|
82
|
+
extraCss = `
|
|
83
|
+
.big{font-family:${grotesk};font-weight:600;color:${c.accent};font-size:${numSize}px;line-height:.92;letter-spacing:-2px}
|
|
84
|
+
.statlabel{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.058)}px;color:${c.ink};margin-top:${Math.round(px.gap * 0.5)}px}`;
|
|
85
|
+
} else {
|
|
86
|
+
const headline = spec.headline || spec.text || 'Your headline here';
|
|
87
|
+
const hSize = fit(Math.round(u * 0.135), headline, 34);
|
|
88
|
+
main = `<div class="rule"></div>
|
|
89
|
+
${kickerHtml}
|
|
90
|
+
<h1 class="headline">${rich(headline)}</h1>
|
|
91
|
+
${subHtml}`;
|
|
92
|
+
extraCss = `
|
|
93
|
+
.headline{font-family:${display};font-weight:600;font-size:${hSize}px;line-height:1.02;letter-spacing:-1.5px}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const html = `<!doctype html>
|
|
97
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
98
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
99
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
100
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Outfit:wght@400;500;600&family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
101
|
+
<style>
|
|
102
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
103
|
+
html,body{width:${size.w}px;height:${size.h}px;overflow:hidden}
|
|
104
|
+
body{background:${c.bg};color:${c.ink};font-family:${body};-webkit-font-smoothing:antialiased}
|
|
105
|
+
.card{width:${size.w}px;height:${size.h}px;padding:${pad}px;display:flex;flex-direction:column;justify-content:center;position:relative}
|
|
106
|
+
.rule{width:${Math.round(u * 0.085)}px;height:${px.rule}px;background:${c.accent};border-radius:${px.rule}px;margin-bottom:${Math.round(px.gap * 0.9)}px}
|
|
107
|
+
.kicker{font-family:${monoFont};font-size:${px.kicker}px;letter-spacing:2px;color:${c.soft};margin-bottom:${Math.round(px.gap * 0.6)}px}
|
|
108
|
+
.sub{font-size:${px.sub}px;line-height:1.4;color:${c.soft};margin-top:${px.gap}px;max-width:88%}
|
|
109
|
+
.foot{position:absolute;left:${pad}px;right:${pad}px;bottom:${pad}px;display:flex;align-items:baseline;justify-content:space-between}
|
|
110
|
+
.mark{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.034)}px}
|
|
111
|
+
.ver{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft};letter-spacing:1px}
|
|
112
|
+
.accent{color:${c.accent}}
|
|
113
|
+
${extraCss}
|
|
114
|
+
</style></head>
|
|
115
|
+
<body><div class="card">${main}${foot}</div></body></html>`;
|
|
116
|
+
|
|
117
|
+
return { html, width: size.w, height: size.h, kind, theme: spec.theme || 'atris', size: spec.size || 'og' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { buildCard, SIZES, KINDS };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Markdown -> deck spec. Lets a PM write an ordinary doc and get a designed deck.
|
|
2
|
+
// Pure: a markdown string in, a { theme, brand, slides } spec out (fed to buildDeck).
|
|
3
|
+
//
|
|
4
|
+
// Mapping (predictable on purpose):
|
|
5
|
+
// --- front matter --- theme / brand / accent
|
|
6
|
+
// # H1 (first) -> title slide (sub = the paragraph under it)
|
|
7
|
+
// ## H2 with 2-4 bullets -> columns slide (each bullet "**Lead** rest" -> a column)
|
|
8
|
+
// ## H2 with "**X** label" only -> bignumber slide
|
|
9
|
+
// ## H2 titled Close/CTA/Thanks -> close slide
|
|
10
|
+
// ## H2 otherwise -> statement slide (sub = the paragraph under it)
|
|
11
|
+
// Emphasis (**bold**) is preserved and rendered in the accent by the engine.
|
|
12
|
+
|
|
13
|
+
function parseFrontMatter(md) {
|
|
14
|
+
const out = { theme: null, brand: null, accent: null };
|
|
15
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
16
|
+
if (!m) return { body: md, fm: out };
|
|
17
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
18
|
+
const kv = line.match(/^([A-Za-z_]+):\s*(.+?)\s*$/);
|
|
19
|
+
if (!kv) continue;
|
|
20
|
+
const k = kv[1].toLowerCase();
|
|
21
|
+
const v = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
22
|
+
if (k === 'theme') out.theme = v;
|
|
23
|
+
else if (k === 'brand') out.brand = v;
|
|
24
|
+
else if (k === 'accent') out.accent = v;
|
|
25
|
+
}
|
|
26
|
+
return { body: md.slice(m[0].length), fm: out };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// group a section's lines into bullets[] and paragraphs[]
|
|
30
|
+
function splitBody(lines) {
|
|
31
|
+
const bullets = [];
|
|
32
|
+
const paras = [];
|
|
33
|
+
let buf = [];
|
|
34
|
+
const flush = () => { if (buf.length) { paras.push(buf.join(' ').trim()); buf = []; } };
|
|
35
|
+
for (const raw of lines) {
|
|
36
|
+
const line = raw.replace(/\s+$/, '');
|
|
37
|
+
if (/^\s*[-*+]\s+/.test(line)) { flush(); bullets.push(line.replace(/^\s*[-*+]\s+/, '').trim()); }
|
|
38
|
+
else if (/^\s*$/.test(line)) flush();
|
|
39
|
+
else if (/^\s*>\s+/.test(line)) { flush(); paras.push(line.replace(/^\s*>\s+/, '').trim()); }
|
|
40
|
+
else buf.push(line.trim());
|
|
41
|
+
}
|
|
42
|
+
flush();
|
|
43
|
+
return { bullets, paras: paras.filter(Boolean) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// "**Lead** rest" / "Lead: rest" / "Lead - rest" -> { h, b }
|
|
47
|
+
function toColumn(bullet) {
|
|
48
|
+
let m = bullet.match(/^\*\*(.+?)\*\*[\s:.-]*(.*)$/);
|
|
49
|
+
if (m) return { h: m[1].trim(), b: m[2].trim() };
|
|
50
|
+
m = bullet.match(/^([^:]{2,48}):\s+(.+)$/);
|
|
51
|
+
if (m) return { h: m[1].trim(), b: m[2].trim() };
|
|
52
|
+
return { h: bullet.trim(), b: '' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// a single "**value** label" paragraph -> big number slide
|
|
56
|
+
function asBigNumber(paras, bullets) {
|
|
57
|
+
if (bullets.length || paras.length !== 1) return null;
|
|
58
|
+
const m = paras[0].match(/^\*\*([^*]{1,18})\*\*\s+(.+)$/);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return { type: 'bignumber', number: m[1].trim(), label: m[2].trim() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CLOSE_TITLES = /^(close|thanks|thank you|cta|call to action|get started|wrap up|next steps?)$/i;
|
|
64
|
+
|
|
65
|
+
function parseMarkdownToSpec(md, opts = {}) {
|
|
66
|
+
const { body, fm } = parseFrontMatter(String(md || ''));
|
|
67
|
+
const theme = opts.theme || fm.theme || 'terminal';
|
|
68
|
+
const brand = {
|
|
69
|
+
name: opts.brandName || fm.brand || 'Atris',
|
|
70
|
+
accent: opts.accent || fm.accent || '.',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// collect sections by heading
|
|
74
|
+
const sections = [];
|
|
75
|
+
let cur = null;
|
|
76
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
77
|
+
const h1 = raw.match(/^#\s+(.+)/);
|
|
78
|
+
const h2 = raw.match(/^##\s+(.+)/);
|
|
79
|
+
if (h1) { cur = { level: 1, title: h1[1].trim(), lines: [] }; sections.push(cur); }
|
|
80
|
+
else if (h2) { cur = { level: 2, title: h2[1].trim(), lines: [] }; sections.push(cur); }
|
|
81
|
+
else if (cur) cur.lines.push(raw);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const slides = [];
|
|
85
|
+
sections.forEach((sec, idx) => {
|
|
86
|
+
const { bullets, paras } = splitBody(sec.lines);
|
|
87
|
+
const firstPara = paras[0] || '';
|
|
88
|
+
|
|
89
|
+
if (sec.level === 1 && slides.length === 0) {
|
|
90
|
+
slides.push({ type: 'title', headline: sec.title, sub: firstPara || undefined });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (CLOSE_TITLES.test(sec.title)) {
|
|
94
|
+
slides.push({ type: 'close', tagline: firstPara || sec.title, footer: bullets[0] || undefined });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const big = asBigNumber(paras, bullets);
|
|
98
|
+
if (big) { slides.push(big); return; }
|
|
99
|
+
if (bullets.length >= 2 && bullets.length <= 4 && bullets.every((b) => b.length <= 160)) {
|
|
100
|
+
slides.push({ type: 'columns', heading: sec.title, columns: bullets.map(toColumn) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
slides.push({ type: 'statement', text: sec.title, sub: firstPara || undefined });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!slides.length) slides.push({ type: 'statement', text: brand.name });
|
|
107
|
+
return { theme, brand, slides };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { parseMarkdownToSpec, parseFrontMatter, splitBody, toColumn };
|