domma-cms 0.25.16 → 0.30.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.
@@ -0,0 +1,280 @@
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).
6
+ *
7
+ * Run: node scripts/migrate-docs.js
8
+ */
9
+ import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
10
+ import {dirname, join} from 'node:path';
11
+
12
+ const TPL = 'admin/js/templates/docs';
13
+ const OUT = 'content/pages/domma-docs';
14
+ const CUSTOM_CSS = 'content/custom.css';
15
+
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.
24
+ 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
+ .doc-breadcrumb a { color: var(--dm-primary); text-decoration: none; }
26
+ .doc-breadcrumb a:hover { text-decoration: underline; }
27
+ .doc-breadcrumb [aria-current] { color: var(--dm-text); font-weight: 600; }
28
+ .doc-pagenav { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: space-between; align-items: center; max-width: 80ch; margin: 2.5rem auto 3rem; }
29
+ .doc-pagenav .btn-secondary { margin-inline: auto; }
30
+ .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
+
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}
37
+ const MAP = [
38
+ ['usage-pages', 'usage/pages', 'Pages'],
39
+ ['usage-media', 'usage/media', 'Media'],
40
+ ['usage-navigation', 'usage/navigation', 'Navigation'],
41
+ ['usage-dconfig', 'usage/dconfig', 'DConfig'],
42
+ ['usage-shortcodes', 'usage/shortcodes', 'Shortcodes'],
43
+ ['usage-site-settings', 'usage/site-settings', 'Site Settings'],
44
+ ['usage-plugins', 'usage/plugins', 'Plugins'],
45
+ ['usage-users-roles', 'usage/users-roles', 'Users & Roles'],
46
+ ['usage-views', 'usage/views', 'Views'],
47
+ ['usage-actions', 'usage/actions', 'Actions'],
48
+ ['usage-cta-shortcode', 'usage/cta-shortcode', 'CTA Shortcode'],
49
+ ['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
50
+ ['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
51
+ ['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
52
+ ['components-reference', 'components', 'Components Reference'],
53
+ ['components-howto', 'components/howto', 'Components How-To'],
54
+ ['components-walkthrough','components/walkthrough','Components Walkthrough'],
55
+ ['components-rules', 'components/rules', 'Components Rules'],
56
+ ['api-authentication', 'api/authentication', 'Authentication'],
57
+ ['api-pages', 'api/pages', 'Pages API'],
58
+ ['api-settings', 'api/settings', 'Settings API'],
59
+ ['api-layouts', 'api/layouts', 'Layouts API'],
60
+ ['api-navigation', 'api/navigation', 'Navigation API'],
61
+ ['api-media', 'api/media', 'Media API'],
62
+ ['api-users', 'api/users', 'Users API'],
63
+ ['api-plugins', 'api/plugins', 'Plugins API'],
64
+ ['api-collections', 'api/collections', 'Collections API'],
65
+ ['api-views', 'api/views', 'Views API'],
66
+ ['api-actions', 'api/actions', 'Actions API']
67
+ ];
68
+
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.
81
+ const LINK_MAP = {
82
+ '#/docs/components-walkthrough': '/domma-docs/components/walkthrough',
83
+ '#/docs/components-rules': '/domma-docs/components/rules',
84
+ '#/docs/components-howto': '/domma-docs/components/howto',
85
+ '#/docs/components': '/domma-docs/components',
86
+ '#/components': '/domma-docs/components',
87
+ '#/tutorials/plugin': '/domma-docs/tutorials/plugin',
88
+ '#/tutorials/forms': '/domma-docs/tutorials/forms',
89
+ '#/tutorials': '/domma-docs/tutorials',
90
+ '#/documentation': '/domma-docs',
91
+ '#/actions': '/admin#/actions',
92
+ '#/collections': '/admin#/collections',
93
+ '#/forms': '/admin#/forms',
94
+ '#/plugins': '/admin#/plugins',
95
+ '#/settings': '/admin#/settings',
96
+ '/resources/shortcodes': '/domma-docs/usage/shortcodes',
97
+ '/resources/components': '/domma-docs/components'
98
+ };
99
+ function rewriteLinks(html) {
100
+ let out = html;
101
+ for (const [from, to] of Object.entries(LINK_MAP)) {
102
+ out = out.split(`href="${from}"`).join(`href="${to}"`);
103
+ }
104
+ // Any remaining /resources/… link has no doc equivalent — unwrap to text.
105
+ out = out.replace(/<a\b[^>]*\shref="\/resources\/[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
106
+ return out;
107
+ }
108
+
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.
111
+ function stripStyleBlocks(html) {
112
+ return html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>\s*/gi, (_m, css) => {
113
+ const trimmed = css.trim();
114
+ if (trimmed) collectedStyles.add(trimmed);
115
+ return '';
116
+ });
117
+ }
118
+
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.
125
+ function dedentOutsidePre(html) {
126
+ const parts = html.split(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi);
127
+ return parts.map((part, i) =>
128
+ (i % 2 === 1) ? part : part.replace(/^[ \t]+/gm, '')
129
+ ).join('');
130
+ }
131
+
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.
137
+ function escapeCodeBrackets(html) {
138
+ 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>
141
+ return html;
142
+ }
143
+
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.
185
+ function unwrapDocsBody(html) {
186
+ return html.trim()
187
+ .replace(/^<div class="row">\s*<div class="col-12">\s*<div class="docs-body">/, '')
188
+ .replace(/<\/div>\s*<\/div>\s*<\/div>\s*$/, '')
189
+ .trim();
190
+ }
191
+
192
+ // Plain-text heading title, safe to use as a shortcode attribute value.
193
+ function headingText(inner) {
194
+ return inner.replace(/<[^>]+>/g, '')
195
+ .replace(/&amp;/g, '&').replace(/&mdash;/g, '—').replace(/&ndash;/g, '–')
196
+ .replace(/&quot;/g, "'").replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
197
+ .replace(/\s+/g, ' ').trim().replace(/"/g, "'");
198
+ }
199
+
200
+ // Split inner content at top-level <hN> headings into {preamble, sections}.
201
+ function splitSections(inner, level) {
202
+ const re = new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)<\\/h${level}>`, 'gi');
203
+ const matches = [...inner.matchAll(re)];
204
+ if (!matches.length) return {preamble: inner, sections: []};
205
+ const preamble = inner.slice(0, matches[0].index).trim();
206
+ const sections = matches.map((m, i) => ({
207
+ title: headingText(m[1]),
208
+ body: inner.slice(m.index + m[0].length, i + 1 < matches.length ? matches[i + 1].index : inner.length).trim()
209
+ }));
210
+ return {preamble, sections};
211
+ }
212
+
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.
216
+ function structureBody(body) {
217
+ const inner = unwrapDocsBody(body);
218
+ const level = /<h2\b/i.test(inner) ? 2 : 3;
219
+ const {preamble, sections} = splitSections(inner, level);
220
+ if (sections.length < 2) return `[card]\n${inner}\n[/card]`;
221
+ const lead = preamble ? `[card]\n${preamble}\n[/card]\n\n` : '';
222
+ if (sections.length <= 6) {
223
+ return lead + '[tabs]\n' +
224
+ sections.map(s => `[tab title="${s.title}"]\n${s.body}\n[/tab]`).join('\n') + '\n[/tabs]';
225
+ }
226
+ return lead + '[accordion multiple="true"]\n' +
227
+ sections.map(s => `[item title="${s.title}"]\n${s.body}\n[/item]`).join('\n') + '\n[/accordion]';
228
+ }
229
+
230
+ // Assemble a content page: full-width hero, breadcrumb, structured body, and a
231
+ // prev / back-to-section / next nav bar (Domma .btn styles).
232
+ function buildPage(section, title, body, prev, next) {
233
+ const hero = `[hero title="${heroAttr(title)}" tagline="${heroAttr(section.label)}" variant="gradient" align="center" fullwidth="true"][/hero]`;
234
+ const nav = docNav(section.label);
235
+ const crumb = `<nav class="doc-breadcrumb"><a href="/domma-docs">Docs</a><span>/</span>` +
236
+ `<a href="${section.href}">${escText(section.label)}</a><span>/</span>` +
237
+ `<span aria-current="page">${escText(title)}</span></nav>`;
238
+ const prevA = prev ? `<a class="btn btn-ghost" href="/domma-docs/${prev[1]}">← ${escText(prev[2])}</a>` : '<span></span>';
239
+ const nextA = next ? `<a class="btn btn-ghost" href="/domma-docs/${next[1]}">${escText(next[2])} →</a>` : '<span></span>';
240
+ const pagenav = `<nav class="doc-pagenav">${prevA}` +
241
+ `<a class="btn btn-secondary" href="${section.href}">All ${escText(section.label)}</a>${nextA}</nav>`;
242
+ return `${hero}\n\n${nav}\n\n${crumb}\n\n${structureBody(body)}\n\n${pagenav}`;
243
+ }
244
+
245
+ let n = 0;
246
+ for (let i = 0; i < MAP.length; i++) {
247
+ const [tpl, path, title] = MAP[i];
248
+ const section = sectionFor(path);
249
+ const src = readFileSync(join(TPL, `${tpl}.html`), 'utf8');
250
+ const body = escapeCodeBrackets(dedentOutsidePre(rewriteLinks(stripStyleBlocks(stripHeader(src))))).trim();
251
+ // prev / next are the section-mates either side of this page in MAP order.
252
+ const mates = MAP.filter(m => sectionFor(m[1]).label === section.label);
253
+ const mi = mates.findIndex(m => m[1] === path);
254
+ const prev = mi > 0 ? mates[mi - 1] : null;
255
+ 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++;
260
+ }
261
+ console.log(`[migrate-docs] wrote ${n} pages to ${OUT}`);
262
+
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}`;
270
+
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}`);