domma-cms 0.30.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +4 -4
  3. package/admin/js/lib/themes.js +1 -1
  4. package/admin/js/templates/effects.html +752 -752
  5. package/admin/js/templates/forms.html +17 -17
  6. package/admin/js/templates/my-profile.html +17 -17
  7. package/admin/js/templates/role-editor.html +70 -70
  8. package/admin/js/templates/roles.html +10 -10
  9. package/admin/js/views/settings.js +1 -1
  10. package/bin/lib/config-merge.js +44 -44
  11. package/config/site.json +1 -0
  12. package/package.json +2 -2
  13. package/plugins/surveys/admin/templates/audience.html +47 -0
  14. package/plugins/surveys/admin/templates/results.html +58 -0
  15. package/plugins/surveys/admin/templates/survey-editor.html +141 -0
  16. package/plugins/surveys/admin/templates/surveys.html +25 -0
  17. package/plugins/surveys/admin/views/audience.js +301 -0
  18. package/plugins/surveys/admin/views/results.js +172 -0
  19. package/plugins/surveys/admin/views/survey-editor.js +211 -0
  20. package/plugins/surveys/admin/views/surveys.js +161 -0
  21. package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
  22. package/plugins/surveys/collections/survey-groups/schema.json +11 -0
  23. package/plugins/surveys/collections/survey-invites/schema.json +16 -0
  24. package/plugins/surveys/collections/surveys/schema.json +23 -0
  25. package/plugins/surveys/config.js +8 -0
  26. package/plugins/surveys/plugin.js +169 -0
  27. package/plugins/surveys/plugin.json +65 -0
  28. package/plugins/surveys/public/dist-shared.mjs +1 -0
  29. package/plugins/surveys/public/survey.css +1 -0
  30. package/plugins/surveys/public/survey.mjs +1 -0
  31. package/plugins/surveys/templates/survey-page.html +63 -0
  32. package/server/middleware/auth.js +253 -253
  33. package/server/routes/api/auth.js +309 -309
  34. package/server/routes/api/navigation.js +42 -42
  35. package/server/routes/api/settings.js +141 -141
  36. package/server/routes/docs-public.js +43 -0
  37. package/server/routes/public.js +2 -2
  38. package/server/server.js +13 -1
  39. package/{scripts/migrate-docs.js → server/services/docs.js} +204 -126
  40. package/server/services/email.js +167 -167
  41. package/server/services/userProfiles.js +199 -199
  42. package/server/services/users.js +302 -302
  43. package/config/connections.json.bak +0 -9
@@ -1,26 +1,39 @@
1
1
  /**
2
- * One-shot porter: admin documentation screens → public page artefacts under
3
- * the Domma Docs project. Reads each admin HTML template, strips the admin-only
4
- * view header, wraps the body in page frontmatter tagged `project: domma-docs`,
5
- * and writes content/pages/domma-docs/<path>.md. Re-runnable (overwrites).
2
+ * Domma Docs request-time renderer
6
3
  *
7
- * Run: node scripts/migrate-docs.js
4
+ * Serves the Domma CMS handbook at /domma-docs/* directly from the admin doc
5
+ * HTML templates (admin/js/templates/docs/*.html) at request time. There is no
6
+ * baked content: a domma-cms version bump ships new templates and the docs
7
+ * update everywhere automatically — no porter, no per-site provisioning, no
8
+ * staleness. (Replaces the one-shot scripts/migrate-docs.js bake.)
9
+ *
10
+ * The publish switch is the `domma-docs` project's enabled flag, enforced by
11
+ * the route (server/routes/docs-public.js); this service only renders.
12
+ *
13
+ * Pipeline per leaf page: read template → strip admin chrome → rewrite links →
14
+ * extract inline <style> → dedent → escape code brackets → structure body into
15
+ * native Domma shortcodes ([card]/[tabs]/[accordion]) → wrap with hero, nav,
16
+ * breadcrumb and prev/next → parseMarkdown → renderPage. Section landing pages
17
+ * (/domma-docs and the Usage/Tutorials/API indexes) are synthesized from the
18
+ * MAP so the nav never links to a 404.
8
19
  */
9
- import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
10
- import {dirname, join} from 'node:path';
20
+ import {readFile} from 'node:fs/promises';
21
+ import {fileURLToPath} from 'node:url';
22
+ import {dirname, join, resolve} from 'node:path';
11
23
 
12
- const TPL = 'admin/js/templates/docs';
13
- const OUT = 'content/pages/domma-docs';
14
- const CUSTOM_CSS = 'content/custom.css';
24
+ import {parseMarkdown} from './markdown.js';
25
+ import {renderPage} from './renderer.js';
26
+ import * as cache from './cache/index.js';
15
27
 
16
- // Page chrome (hero, cards, grid, buttons) uses native Domma shortcode
17
- // components see wrapPage() and the landing pages so they get the framework's
18
- // own designs and effects. The only custom CSS we keep is whatever inline
19
- // <style> the templates carried (e.g. API method badges), collected below. Set
20
- // to '' so no hand-rolled layout CSS competes with Domma's components.
21
- // Only functional nav chrome (breadcrumb + prev/next bar) Domma has no
22
- // Markdown breadcrumb component. Everything else (hero, cards, tabs, accordion,
23
- // buttons) is a native Domma component. Token-based so it follows the theme.
28
+ // import.meta.url-relative (NOT cwd-relative): the templates live beside the
29
+ // engine's admin dir in both the npm-package layout and each child site's
30
+ // physical copy. server/services/docs.js ../../admin/js/templates/docs.
31
+ const TPL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..', 'admin/js/templates/docs');
32
+
33
+ // Functional nav chrome only (breadcrumb + prev/next + section nav). Everything
34
+ // else (hero, cards, tabs, accordion, buttons) is a native Domma component, so
35
+ // it follows the theme. Injected as an inline <style> per docs page at render
36
+ // time — no dependency on content/custom.css.
24
37
  const DOCS_BODY_CSS = `.doc-breadcrumb { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; max-width: 80ch; margin: 0 auto 1.25rem; font-size: .85rem; color: var(--dm-text-muted); }
25
38
  .doc-breadcrumb a { color: var(--dm-primary); text-decoration: none; }
26
39
  .doc-breadcrumb a:hover { text-decoration: underline; }
@@ -29,11 +42,7 @@ const DOCS_BODY_CSS = `.doc-breadcrumb { display: flex; flex-wrap: wrap; gap: .5
29
42
  .doc-pagenav .btn-secondary { margin-inline: auto; }
30
43
  .doc-nav { display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center; max-width: 80ch; margin: 0 auto 1.75rem; padding: .5rem; border: 1px solid var(--dm-card-border, var(--dm-border)); border-radius: var(--dm-radius-lg, 14px); background: var(--dm-card-bg, var(--dm-surface)); }`;
31
44
 
32
- // Inline <style> blocks pulled out of the templates are accumulated here
33
- // (deduped) and written to custom.css with the base styles.
34
- const collectedStyles = new Set();
35
-
36
- // template basename → {path (URL tail = file path), title}
45
+ // template basename [url tail (= file path), title]
37
46
  const MAP = [
38
47
  ['usage-pages', 'usage/pages', 'Pages'],
39
48
  ['usage-media', 'usage/media', 'Media'],
@@ -66,18 +75,9 @@ const MAP = [
66
75
  ['api-actions', 'api/actions', 'Actions API']
67
76
  ];
68
77
 
69
- // Drop the admin-only header block (<div class="view-header"> ... </div>).
70
- // The header contains no nested <div>, so a non-greedy match to the first
71
- // </div> is correct.
72
- function stripHeader(html) {
73
- return html.replace(/<div class="view-header">[\s\S]*?<\/div>\s*/, '');
74
- }
75
-
76
- // The admin templates link with admin-SPA hash routes (#/…) and stale
77
- // /resources/… paths, none of which resolve on the public site. Rewrite them:
78
- // doc-to-doc links → public /domma-docs/… paths; admin-feature links → the
79
- // admin SPA (/admin#/…); resources links with a doc equivalent → that doc; the
80
- // rest of /resources/… (no equivalent) are unwrapped to plain text.
78
+ // Admin-SPA hash routes and stale /resources/… paths don't resolve on the
79
+ // public site rewrite doc-to-doc links to /domma-docs/…, admin-feature links
80
+ // to the admin SPA, and unwrap the rest.
81
81
  const LINK_MAP = {
82
82
  '#/docs/components-walkthrough': '/domma-docs/components/walkthrough',
83
83
  '#/docs/components-rules': '/domma-docs/components/rules',
@@ -96,6 +96,39 @@ const LINK_MAP = {
96
96
  '/resources/shortcodes': '/domma-docs/usage/shortcodes',
97
97
  '/resources/components': '/domma-docs/components'
98
98
  };
99
+
100
+ // The four top-level doc sections — rendered as a persistent nav on every page.
101
+ const SECTIONS = [
102
+ {label: 'Usage', href: '/domma-docs/usage'},
103
+ {label: 'Tutorials', href: '/domma-docs/tutorials'},
104
+ {label: 'Components', href: '/domma-docs/components'},
105
+ {label: 'API Reference', href: '/domma-docs/api'}
106
+ ];
107
+
108
+ // Section landing pages keyed by url tail → section label. The Components
109
+ // landing is the Components Reference leaf (MAP tail 'components'), so it is
110
+ // NOT synthesized here.
111
+ const LANDINGS = {
112
+ '': 'Home',
113
+ 'usage': 'Usage',
114
+ 'tutorials': 'Tutorials',
115
+ 'api': 'API Reference'
116
+ };
117
+
118
+ // ── pure transform helpers (relocated from scripts/migrate-docs.js) ──────────
119
+
120
+ function escText(s) {
121
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
122
+ }
123
+ function heroAttr(s) {
124
+ return String(s).replace(/"/g, '&quot;');
125
+ }
126
+
127
+ // Drop the admin-only header block (<div class="view-header"> … </div>).
128
+ function stripHeader(html) {
129
+ return html.replace(/<div class="view-header">[\s\S]*?<\/div>\s*/, '');
130
+ }
131
+
99
132
  function rewriteLinks(html) {
100
133
  let out = html;
101
134
  for (const [from, to] of Object.entries(LINK_MAP)) {
@@ -106,22 +139,22 @@ function rewriteLinks(html) {
106
139
  return out;
107
140
  }
108
141
 
109
- // Pull every inline <style> block out of the body (collecting its CSS for
110
- // custom.css) so no styling is lumped into the page content.
142
+ // Pull every inline <style> block out of the body, returning the cleaned HTML
143
+ // plus the extracted (deduped) CSS collected PER CALL (no shared module state,
144
+ // which would leak across requests in a long-lived server).
111
145
  function stripStyleBlocks(html) {
112
- return html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>\s*/gi, (_m, css) => {
146
+ const styles = new Set();
147
+ const out = html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>\s*/gi, (_m, css) => {
113
148
  const trimmed = css.trim();
114
- if (trimmed) collectedStyles.add(trimmed);
149
+ if (trimmed) styles.add(trimmed);
115
150
  return '';
116
151
  });
152
+ return {html: out, styles: [...styles]};
117
153
  }
118
154
 
119
- // The admin templates are deeply indented. Markdown treats any line indented
120
- // 4+ spaces as a code block, which would render the structural HTML (tables,
121
- // divs, headings) as escaped source text. Strip leading indentation from every
122
- // line OUTSIDE <pre> blocks so each structural tag starts at column 0 and marked
123
- // passes it through as raw HTML. Inside <pre>, whitespace is significant (code
124
- // samples) and is preserved verbatim.
155
+ // Markdown treats any line indented 4+ spaces as a code block. Strip leading
156
+ // indentation OUTSIDE <pre> so structural HTML passes through as raw HTML;
157
+ // inside <pre> whitespace is significant and preserved.
125
158
  function dedentOutsidePre(html) {
126
159
  const parts = html.split(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi);
127
160
  return parts.map((part, i) =>
@@ -129,59 +162,16 @@ function dedentOutsidePre(html) {
129
162
  ).join('');
130
163
  }
131
164
 
132
- // Shortcode examples in the docs live inside <code>/<pre>. The render pipeline's
133
- // code-scrubber protects <pre> and backtick spans but NOT bare inline <code>,
134
- // so examples like [grid cols="N"]...[/grid] in table cells would be expanded
135
- // as real shortcodes. Escape square brackets inside every <pre> and <code>
136
- // region so all examples render literally on the public page.
165
+ // Escape square brackets inside <pre> and <code> so shortcode examples render
166
+ // literally instead of being expanded by the render pipeline.
137
167
  function escapeCodeBrackets(html) {
138
168
  const esc = (region) => region.replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
139
- html = html.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, esc); // <pre> first (incl. nested <code>)
140
- html = html.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, esc); // then standalone inline <code>
169
+ html = html.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, esc);
170
+ html = html.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, esc);
141
171
  return html;
142
172
  }
143
173
 
144
- function frontmatter(title) {
145
- return `---\ntitle: ${title}\nlayout: default\nstatus: published\nvisibility: public\nproject: domma-docs\n---\n\n`;
146
- }
147
-
148
- // The four top-level doc sections — rendered as a persistent nav on every page.
149
- const SECTIONS = [
150
- {label: 'Usage', href: '/domma-docs/usage'},
151
- {label: 'Tutorials', href: '/domma-docs/tutorials'},
152
- {label: 'Components', href: '/domma-docs/components'},
153
- {label: 'API Reference', href: '/domma-docs/api'}
154
- ];
155
-
156
- // Persistent section nav (Home + the four sections, current one highlighted).
157
- // Rendered just under the hero on every doc page so navigation is always present.
158
- function docNav(activeLabel) {
159
- const links = [{label: 'Home', href: '/domma-docs'}, ...SECTIONS].map(s => {
160
- const active = s.label === activeLabel;
161
- return `<a class="btn btn-sm ${active ? 'btn-primary' : 'btn-ghost'}" href="${s.href}">${escText(s.label)}</a>`;
162
- }).join('');
163
- return `<nav class="doc-nav">${links}</nav>`;
164
- }
165
-
166
- // Section label + landing URL for the hero eyebrow, derived from the page path.
167
- function sectionFor(path) {
168
- if (path.startsWith('usage/')) return {label: 'Usage', href: '/domma-docs/usage'};
169
- if (path.startsWith('tutorials/')) return {label: 'Tutorials', href: '/domma-docs/tutorials'};
170
- if (path.startsWith('api/')) return {label: 'API Reference', href: '/domma-docs/api'};
171
- if (path === 'components' || path.startsWith('components/')) return {label: 'Components', href: '/domma-docs/components'};
172
- return {label: 'Documentation', href: '/domma-docs'};
173
- }
174
-
175
- function heroAttr(s) {
176
- return String(s).replace(/"/g, '&quot;');
177
- }
178
- function escText(s) {
179
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
180
- }
181
-
182
- // Every template wraps its content in a consistent .row > .col-12 > .docs-body
183
- // shell (opening at the top, three closing </div> at the very end). Strip it to
184
- // get the bare inner content so we can split it into sections.
174
+ // Strip the consistent .row > .col-12 > .docs-body shell to get the bare inner.
185
175
  function unwrapDocsBody(html) {
186
176
  return html.trim()
187
177
  .replace(/^<div class="row">\s*<div class="col-12">\s*<div class="docs-body">/, '')
@@ -189,7 +179,7 @@ function unwrapDocsBody(html) {
189
179
  .trim();
190
180
  }
191
181
 
192
- // Plain-text heading title, safe to use as a shortcode attribute value.
182
+ // Plain-text heading title, safe as a shortcode attribute value.
193
183
  function headingText(inner) {
194
184
  return inner.replace(/<[^>]+>/g, '')
195
185
  .replace(/&amp;/g, '&').replace(/&mdash;/g, '—').replace(/&ndash;/g, '–')
@@ -210,9 +200,7 @@ function splitSections(inner, level) {
210
200
  return {preamble, sections};
211
201
  }
212
202
 
213
- // Turn the body into native Domma components. ≥7 sections accordion (a long
214
- // scannable reference); 2–6 → tabs (first pane shown, rest a click away); <2 →
215
- // a single card. Any preamble before the first heading leads in its own card.
203
+ // ≥7 sections accordion; 2–6 tabs; <2 → a single card.
216
204
  function structureBody(body) {
217
205
  const inner = unwrapDocsBody(body);
218
206
  const level = /<h2\b/i.test(inner) ? 2 : 3;
@@ -227,8 +215,26 @@ function structureBody(body) {
227
215
  sections.map(s => `[item title="${s.title}"]\n${s.body}\n[/item]`).join('\n') + '\n[/accordion]';
228
216
  }
229
217
 
230
- // Assemble a content page: full-width hero, breadcrumb, structured body, and a
231
- // prev / back-to-section / next nav bar (Domma .btn styles).
218
+ // Persistent section nav (Home + the four sections, current one highlighted).
219
+ function docNav(activeLabel) {
220
+ const links = [{label: 'Home', href: '/domma-docs'}, ...SECTIONS].map(s => {
221
+ const active = s.label === activeLabel;
222
+ return `<a class="btn btn-sm ${active ? 'btn-primary' : 'btn-ghost'}" href="${s.href}">${escText(s.label)}</a>`;
223
+ }).join('');
224
+ return `<nav class="doc-nav">${links}</nav>`;
225
+ }
226
+
227
+ // Section label + landing URL for the hero eyebrow, derived from the page path.
228
+ function sectionFor(path) {
229
+ if (path.startsWith('usage/')) return {label: 'Usage', href: '/domma-docs/usage'};
230
+ if (path.startsWith('tutorials/')) return {label: 'Tutorials', href: '/domma-docs/tutorials'};
231
+ if (path.startsWith('api/')) return {label: 'API Reference', href: '/domma-docs/api'};
232
+ if (path === 'components' || path.startsWith('components/')) return {label: 'Components', href: '/domma-docs/components'};
233
+ return {label: 'Documentation', href: '/domma-docs'};
234
+ }
235
+
236
+ // Assemble a leaf page: hero, persistent nav, breadcrumb, structured body, and
237
+ // a prev / back-to-section / next nav bar.
232
238
  function buildPage(section, title, body, prev, next) {
233
239
  const hero = `[hero title="${heroAttr(title)}" tagline="${heroAttr(section.label)}" variant="gradient" align="center" fullwidth="true"][/hero]`;
234
240
  const nav = docNav(section.label);
@@ -242,39 +248,111 @@ function buildPage(section, title, body, prev, next) {
242
248
  return `${hero}\n\n${nav}\n\n${crumb}\n\n${structureBody(body)}\n\n${pagenav}`;
243
249
  }
244
250
 
245
- let n = 0;
246
- for (let i = 0; i < MAP.length; i++) {
247
- const [tpl, path, title] = MAP[i];
251
+ // ── landing pages ────────────────────────────────────────────────────────────
252
+
253
+ // Build a section landing body: hero + nav + a grid of cards linking each leaf.
254
+ function buildSectionLanding(label) {
255
+ const section = SECTIONS.find(s => s.label === label);
256
+ const pages = MAP.filter(m => sectionFor(m[1]).label === label);
257
+ const hero = `[hero title="${heroAttr(label)}" tagline="Domma Docs" variant="gradient" align="center" fullwidth="true"][/hero]`;
258
+ const cards = pages.map(([, tail, title]) =>
259
+ `[card]\n### ${escText(title)}\n\n[button label="Read ${heroAttr(title)} →" href="/domma-docs/${tail}" variant="ghost" /]\n[/card]`
260
+ ).join('\n');
261
+ return {title: label, body: `${hero}\n\n${docNav(label)}\n\n[grid cols="3"]\n${cards}\n[/grid]`};
262
+ }
263
+
264
+ // Build the home landing: hero + nav + a card per top-level section.
265
+ function buildHomeLanding() {
266
+ const hero = `[hero title="Domma Docs" tagline="The Domma CMS handbook" variant="gradient" align="center" fullwidth="true"][/hero]`;
267
+ const cards = [
268
+ {label: 'Usage', href: '/domma-docs/usage', icon: 'book-open', blurb: 'Day-to-day guides for running the CMS — pages, media, navigation, shortcodes and more.'},
269
+ {label: 'Tutorials', href: '/domma-docs/tutorials', icon: 'academic-cap', blurb: 'Step-by-step walkthroughs that build something real from start to finish.'},
270
+ {label: 'Components', href: '/domma-docs/components', icon: 'puzzle', blurb: 'The full component reference — shortcodes, how-tos, walkthroughs and rules.'},
271
+ {label: 'API Reference', href: '/domma-docs/api', icon: 'code', blurb: 'Every REST endpoint for headless and external integrations.'}
272
+ ].map(s =>
273
+ `[card icon="${s.icon}" title="${heroAttr(s.label)}"]\n${escText(s.blurb)}\n\n[button label="Browse ${heroAttr(s.label)} →" href="${s.href}" variant="primary" /]\n[/card]`
274
+ ).join('\n');
275
+ return {title: 'Documentation', body: `${hero}\n\n${docNav('Home')}\n\n[grid cols="2"]\n${cards}\n[/grid]`};
276
+ }
277
+
278
+ // ── render ───────────────────────────────────────────────────────────────────
279
+
280
+ // Build the shortcode body for a doc path, or null if the path is unknown.
281
+ // Returns {title, body, styles[]} — `styles` is the inline CSS extracted from a
282
+ // leaf template (empty for synthesized landings).
283
+ async function buildDocBody(docPath) {
284
+ if (docPath in LANDINGS) {
285
+ const built = docPath === '' ? buildHomeLanding() : buildSectionLanding(LANDINGS[docPath]);
286
+ return {...built, styles: []};
287
+ }
288
+ const entry = MAP.find(m => m[1] === docPath);
289
+ if (!entry) return null;
290
+ const [tpl, path, title] = entry;
291
+ let src;
292
+ try {
293
+ src = await readFile(join(TPL_DIR, `${tpl}.html`), 'utf8');
294
+ } catch {
295
+ return null; // template missing — treat as a 404
296
+ }
297
+ const {html: stripped, styles} = stripStyleBlocks(stripHeader(src));
298
+ const body = escapeCodeBrackets(dedentOutsidePre(rewriteLinks(stripped))).trim();
248
299
  const section = sectionFor(path);
249
- const src = readFileSync(join(TPL, `${tpl}.html`), 'utf8');
250
- const body = escapeCodeBrackets(dedentOutsidePre(rewriteLinks(stripStyleBlocks(stripHeader(src))))).trim();
251
300
  // prev / next are the section-mates either side of this page in MAP order.
252
301
  const mates = MAP.filter(m => sectionFor(m[1]).label === section.label);
253
302
  const mi = mates.findIndex(m => m[1] === path);
254
303
  const prev = mi > 0 ? mates[mi - 1] : null;
255
304
  const next = mi >= 0 && mi < mates.length - 1 ? mates[mi + 1] : null;
256
- const dest = join(OUT, `${path}.md`);
257
- mkdirSync(dirname(dest), {recursive: true});
258
- writeFileSync(dest, frontmatter(title) + buildPage(section, title, body, prev, next) + '\n');
259
- n++;
305
+ return {title, body: buildPage(section, title, body, prev, next), styles};
260
306
  }
261
- console.log(`[migrate-docs] wrote ${n} pages to ${OUT}`);
262
307
 
263
- // Write the docs CSS (base + extracted inline styles) into content/custom.css
264
- // as an idempotent managed block, so styling lives in the custom CSS file
265
- // rather than being lumped into the pages.
266
- const BEGIN = '/* >>> Domma Docs styles (managed by scripts/migrate-docs.js) do not edit between markers >>> */';
267
- const END = '/* <<< Domma Docs styles <<< */';
268
- const cssBody = [DOCS_BODY_CSS, ...collectedStyles].filter(Boolean).join('\n\n');
269
- const block = `${BEGIN}\n${cssBody}\n${END}`;
308
+ /**
309
+ * Render a Domma Docs page to full HTML, or return null for an unknown path
310
+ * (the route turns that into a 404). Cached per path+origin under the static
311
+ * 'docs' tag invalidated on boot so a version bump never serves stale HTML.
312
+ *
313
+ * @param {string} docPath url tail after /domma-docs (e.g. 'usage/pages', '' for home)
314
+ * @param {{user?: object|null, baseUrl?: string}} [opts]
315
+ * @returns {Promise<string|null>}
316
+ */
317
+ export async function renderDocPage(docPath, opts = {}) {
318
+ const path = String(docPath || '').replace(/^\/+|\/+$/g, '');
319
+ const built = await buildDocBody(path);
320
+ if (!built) return null;
321
+
322
+ const {user = null, baseUrl} = opts;
323
+ return cache.wrap(
324
+ `docs:${path}:o${baseUrl || ''}`,
325
+ async () => {
326
+ const parsed = await parseMarkdown(built.body, {user});
327
+ const css = [DOCS_BODY_CSS, ...built.styles].filter(Boolean).join('\n\n');
328
+ const html = `<style>${css}</style>\n${parsed.html}`;
329
+ const page = {
330
+ title: built.title,
331
+ layout: 'default',
332
+ status: 'published',
333
+ visibility: 'public',
334
+ project: 'domma-docs',
335
+ urlPath: `/domma-docs${path ? '/' + path : ''}`,
336
+ html,
337
+ usedComponents: parsed.usedComponents || [],
338
+ dconfig: parsed.data?.dconfig || null,
339
+ seo: {title: built.title},
340
+ sidebar: false,
341
+ tags: ['docs']
342
+ };
343
+ return renderPage(page, {baseUrl, user});
344
+ },
345
+ {tags: ['docs']}
346
+ );
347
+ }
270
348
 
271
- let existing = '';
272
- try { existing = readFileSync(CUSTOM_CSS, 'utf8'); } catch { /* file may not exist */ }
273
- const blockRe = new RegExp(
274
- BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
275
- );
276
- const next = blockRe.test(existing)
277
- ? existing.replace(blockRe, block)
278
- : (existing.trim() ? existing.trimEnd() + '\n\n' : '') + block + '\n';
279
- writeFileSync(CUSTOM_CSS, next);
280
- console.log(`[migrate-docs] wrote docs CSS (${collectedStyles.size} extracted block(s) + base) to ${CUSTOM_CSS}`);
349
+ /**
350
+ * Every valid doc path (home + 29 leaves + 3 section landings). The Components
351
+ * landing is the 'components' leaf, already in MAP. `renderDocPage` returning
352
+ * null is the authoritative miss; this is an enumeration aid (e.g. sitemap).
353
+ *
354
+ * @returns {string[]}
355
+ */
356
+ export function listDocPaths() {
357
+ return ['', ...MAP.map(m => m[1]), 'usage', 'tutorials', 'api'];
358
+ }