@threadlabs/looma-docs 0.0.0-stage → 0.52.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -2
  3. package/bin/looma-docs.mjs +3 -0
  4. package/docs/README.md +89 -0
  5. package/docs/card.html +79 -0
  6. package/docs/card.js +18 -0
  7. package/docs/catalog-view.js +24 -0
  8. package/docs/catalog-view.mjs +13 -0
  9. package/docs/catalog.mjs +135 -0
  10. package/docs/cli.mjs +26 -0
  11. package/docs/commands.mjs +94 -0
  12. package/docs/customizer-render.mjs +157 -0
  13. package/docs/customizer.js +494 -0
  14. package/docs/documentation.css +42 -0
  15. package/docs/example-invocations.mjs +63 -0
  16. package/docs/fonts.mjs +73 -0
  17. package/docs/framework-examples.d.mts +9 -0
  18. package/docs/framework-examples.mjs +225 -0
  19. package/docs/guides/01.theme-your-system.md +64 -0
  20. package/docs/guides/02.add-your-components.md +96 -0
  21. package/docs/guides/03.publish-your-system.md +71 -0
  22. package/docs/guides/04.theme-decisions.md +141 -0
  23. package/docs/guides/05.(Theme reference)/01.theme-colors.md +95 -0
  24. package/docs/guides/05.(Theme reference)/02.theme-typography.md +73 -0
  25. package/docs/guides/05.(Theme reference)/03.theme-spacing.md +103 -0
  26. package/docs/guides/05.(Theme reference)/04.theme-material.md +90 -0
  27. package/docs/guides/05.(Theme reference)/05.theme-motion.md +57 -0
  28. package/docs/guides/assets/fellowship-dark.png +0 -0
  29. package/docs/guides/assets/fellowship-light.png +0 -0
  30. package/docs/index.d.ts +59 -0
  31. package/docs/index.mjs +4 -0
  32. package/docs/local-server.mjs +65 -0
  33. package/docs/navigation.mjs +40 -0
  34. package/docs/palette.mjs +68 -0
  35. package/docs/pastoral.svg +54 -0
  36. package/docs/portrait.svg +15 -0
  37. package/docs/reference.html +1 -0
  38. package/docs/reference.js +36 -0
  39. package/docs/render.mjs +97 -0
  40. package/docs/site.mjs +244 -0
  41. package/docs/store.mjs +92 -0
  42. package/docs/theme-session.mjs +72 -0
  43. package/docs/theme.mjs +669 -0
  44. package/docs/value-editors.mjs +37 -0
  45. package/docs/workspace.mjs +32 -0
  46. package/package.json +53 -3
package/docs/site.mjs ADDED
@@ -0,0 +1,244 @@
1
+ import { cp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
6
+ import { cardMarkup, componentPath, exampleKey, previewTag, scopedExampleMarkup, exampleResources, referencePage } from './render.mjs';
7
+ import { defaultRecipe, themeCSS } from './theme.mjs';
8
+ import { customizerResource } from './customizer-render.mjs';
9
+ import { catalogResource } from './catalog-view.mjs';
10
+ import { cacheDirectory, withBuildLock } from './workspace.mjs';
11
+ import { googleFontURL } from './fonts.mjs';
12
+ import { orderNavigation } from './navigation.mjs';
13
+
14
+ const own = name => fileURLToPath(new URL(name, import.meta.url));
15
+ const guideAssets = ['fellowship-light.png', 'fellowship-dark.png'];
16
+ /** Load the documentation package's toolchain in its own dependency scope. */
17
+ export async function tool(name) {
18
+ if (name === 'markupress') return import('markupress');
19
+ if (name === '@threadlabs/markupkit') return import('@threadlabs/markupkit');
20
+ if (name === 'vite') return import('vite');
21
+ throw new Error(`Unknown documentation tool ${name}.`);
22
+ }
23
+ const normalizeBase = base => '/' + String(base ?? '/').replace(/^\/+|\/+$/g, '') + (String(base ?? '/').replaceAll('/', '') ? '/' : '');
24
+ const relativeHref = (from, to) => path.relative(path.dirname(from), to).split(path.sep).join('/');
25
+
26
+ async function authoredFiles(directory) {
27
+ const files = [];
28
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
29
+ if (entry.name.startsWith('.')) continue;
30
+ const file = path.join(directory, entry.name);
31
+ if (entry.isDirectory()) files.push(...await authoredFiles(file)); else files.push(file);
32
+ }
33
+ return files;
34
+ }
35
+
36
+ /** Prepare a disposable documentation tree from published catalogs and opted-in project roots. */
37
+ export async function prepareDocumentation({ root, config = {}, catalog }) {
38
+ root = await realpath(path.resolve(root));
39
+ const base = normalizeBase(config.base);
40
+ const catalogs = [catalog ?? await readCatalog()];
41
+ for (const entry of config.catalogs ?? []) {
42
+ const file = entry.startsWith('.') || path.isAbsolute(entry) ? path.resolve(root, entry) : createRequire(path.join(root, 'package.json')).resolve(entry);
43
+ catalogs.push(await readCatalog(file));
44
+ }
45
+ for (const entry of config.components ?? []) catalogs.push(await discoverComponents({ root, ...entry }));
46
+ const combined = mergeCatalogs(catalogs, { categories: config.categories });
47
+ for (const component of combined.components) {
48
+ component.examples = component.examples.length ? component.examples : [{ id: `${component.id}/default`, name: 'default', title: component.title, description: component.description, markup: `<${component.tag}></${component.tag}>`, frameworks: {}, resizable: false }];
49
+ }
50
+ const generated = await cacheDirectory(root, 'generated');
51
+ const output = path.resolve(root, config.outDir ?? '.looma/site');
52
+ if (output === generated || output.startsWith(generated + path.sep)) throw new Error('Choose an output directory outside .looma/generated, which owns disposable source files.');
53
+ await rm(generated, { recursive: true, force: true });
54
+ await mkdir(generated, { recursive: true });
55
+ let content = path.join(generated, 'docs');
56
+ if (config.content) {
57
+ const relative = path.relative(root, path.resolve(root, config.content));
58
+ if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || relative.split(path.sep).includes('.looma')) throw new Error('Authored content must be a directory inside the project, outside .looma.');
59
+ const mirror = path.join(generated, 'project');
60
+ await rm(mirror, { recursive: true, force: true });
61
+ // Preserve every relative dependency path without copying the project or rewriting code examples.
62
+ const mirrorParents = async (source, target, parts) => {
63
+ await mkdir(target, { recursive: true });
64
+ for (const entry of await readdir(source, { withFileTypes: true })) {
65
+ if (['.looma', '.git', 'node_modules', 'package.json'].includes(entry.name)) continue;
66
+ const original = path.join(source, entry.name), mirrored = path.join(target, entry.name);
67
+ if (entry.name === parts[0]) {
68
+ if (parts.length > 1) await mirrorParents(original, mirrored, parts.slice(1));
69
+ } else await symlink(original, mirrored, entry.isDirectory() ? 'dir' : 'file');
70
+ }
71
+ };
72
+ await mirrorParents(root, mirror, relative.split(path.sep));
73
+ content = path.join(mirror, relative);
74
+ }
75
+ const previews = path.join(generated, 'previews');
76
+ await rm(content, { recursive: true, force: true });
77
+ await rm(previews, { recursive: true, force: true });
78
+ await mkdir(path.join(content, 'components'), { recursive: true });
79
+ await mkdir(previews, { recursive: true });
80
+ const card = path.join(generated, 'card.html');
81
+ await cp(own('card.html'), card);
82
+ await cp(own('card.js'), path.join(generated, 'card.js'));
83
+ const catalogGroups = combined.groups.filter(group => combined.components.some(component => component.group === group.id && !component.navigationParent));
84
+ await writeFile(path.join(generated, 'catalog-view.html'), catalogResource(catalogGroups, base));
85
+ await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
86
+ for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
87
+ for (const file of ['theme.mjs', 'theme-session.mjs', 'value-editors.mjs', 'palette.mjs', 'fonts.mjs']) await cp(own(file), path.join(generated, file));
88
+ // Keep the controller's imports in Looma's package scope, where its runtime dependency lives.
89
+ await writeFile(path.join(generated, 'customizer.js'), `export { connect } from ${JSON.stringify(own('customizer.js'))};\n`);
90
+ const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
91
+ const examples = [];
92
+ // MarkupKit route groups retain their labels/order in navigation without changing public URLs.
93
+ const groupDirectories = new Map(combined.groups.map((group, index) => [group.id,
94
+ `${String(index + 1).padStart(2, '0')}.(${group.label.replace(/[\\/()]/g, '-')})`]));
95
+ const generatedReferences = new Set();
96
+ const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
97
+ for (const component of combined.components) {
98
+ if (!component.navigationParent) {
99
+ const section = component.group === 'editor' ? ['editor'] : ['components', groupDirectories.get(component.group)];
100
+ const page = path.join(content, ...section, component.tag + '.md');
101
+ await write(page, referencePage(component, combined, relativeHref(page, card), base));
102
+ generatedReferences.add(`${component.group === 'editor' ? 'editor' : 'components'}/${component.tag}.md`);
103
+ }
104
+ for (const example of component.examples) {
105
+ const key = exampleKey(example.id);
106
+ const file = path.join(previews, key + '.html');
107
+ const tags = [...new Set([...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1]))];
108
+ const links = tags.filter(tag => resourceByTag.has(tag)).map(tag => `<link rel="component" href="${escapeHtml(resourceByTag.get(tag).resource)}">`).join('\n');
109
+ const setup = path.join(previews, key + '.js');
110
+ if (example.behavior) await write(setup, `import setupExample from ${JSON.stringify(example.behavior)};\nexport function connect(host) { return setupExample(host.element); }\n`);
111
+ await write(file, `${links}\n<template component="${previewTag(example)}"${example.behavior ? ` module="./${key}.js"` : ''}><defs><prop name="namespace" type="string" default="${key}">A unique namespace for this example's IDs and control relationships.</prop></defs><div><div data-preview="${escapeHtml(example.id)}"${example.stretch || component.group === 'layout' ? ' data-preview-layout' : ''}>${scopedExampleMarkup(example, base)}</div></div></template>\n`);
112
+ examples.push({ ...example, file, component: component.id, path: base + componentPath(component).slice(1) });
113
+ }
114
+ }
115
+ const gallery = selected => catalogGroups.filter(group => !selected || group.id === selected).map(group => {
116
+ const components = combined.components.filter(c => c.group === group.id && !c.navigationParent);
117
+ // A standalone opening tag makes this a Markdown HTML block, keeping native card wrappers inside Grid.
118
+ return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}" aria-label="${escapeHtml(group.label)} components">\n\n${selected ? '' : `## ${escapeHtml(group.label)}\n\n`}<ui-grid columns="2" min="sm">\n${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.summary ?? ''} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('\n')}\n</ui-grid>\n\n</section>` : '';
119
+ }).join('\n\n');
120
+ const writeCatalog = async (file, title, selected = '') => {
121
+ const total = combined.components.filter(component => !component.navigationParent && (!selected || component.group === selected)).length;
122
+ await write(file, `---\ntitle: ${JSON.stringify(title)}\n${selected ? 'navigation: overview\norder: 0\n' : ''}---\n\n# ${escapeHtml(title)}\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(file, card)}">\n${exampleResources(combined.components.filter(component => !component.navigationParent && (!selected || component.group === selected)).map(component => component.examples[0]), combined)}\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(file, path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog category="${escapeHtml(selected)}" total="${total}">\n\n${gallery(selected)}\n\n</looma-catalog>\n`);
123
+ };
124
+ await writeCatalog(path.join(content, 'components/index.md'), 'Components');
125
+ for (const group of catalogGroups) {
126
+ await writeCatalog(path.join(content, 'components', groupDirectories.get(group.id), `00.${group.id}.md`), group.label, group.id);
127
+ generatedReferences.add(`components/${group.id}.md`);
128
+ }
129
+ await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
130
+ if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\nsidebar: false\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
131
+ const guideDirectory = path.join(content, '02.(Create your design system)');
132
+ await write(path.join(guideDirectory, '00.design-system.md'), await readFile(own('README.md'), 'utf8'));
133
+ await cp(own('guides/'), guideDirectory, { recursive: true });
134
+ if (config.content) {
135
+ const authored = path.resolve(root, config.content);
136
+ for (const source of await authoredFiles(authored)) {
137
+ const target = path.join(content, path.relative(authored, source));
138
+ const publicPath = path.relative(authored, source).split(path.sep)
139
+ .filter(segment => !/^\d+\.\([^)]*\)$/.test(segment)).map(segment => segment.replace(/^\d+\./, '')).join('/');
140
+ if (generatedReferences.has(publicPath)) throw new Error(`Authored/generated documentation collision: ${source} would replace a generated component reference. Add guidance under another path.`);
141
+ let exists = false;
142
+ try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
143
+ // A project's landing page and footer are branding/content, never API overrides.
144
+ if (exists && !['index.md', 'editor.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
145
+ await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
146
+ }
147
+ }
148
+ await write(path.join(generated, 'example-behaviors.js'), examples.filter(example => example.behavior).map((example, index) => `import setup${index} from ${JSON.stringify(example.behavior)};`).join('\n') + `\nexport const exampleBehaviors = {${examples.filter(example => example.behavior).map((example, index) => `${JSON.stringify(example.id)}: setup${index}`).join(',')}};\n`);
149
+ const choice = exampleResources(examples, combined) + `\n<template component="looma-preview-choice"><defs><prop name="identity" type="string" default="">The authored example to render.</prop><prop name="namespace" type="string" default="selected">Unique ID namespace within this page.</prop></defs><div>${examples.map(example => `<div $if="${escapeHtml(`$identity = ${JSON.stringify(example.id)}`)}" data-preview="${escapeHtml(example.id)}"${example.stretch || combined.components.find(component => component.id === example.component)?.group === 'layout' ? ' data-preview-layout' : ''}>${scopedExampleMarkup(example, base)}</div>`).join('')}</div></template>`;
150
+ await write(path.join(generated, 'preview-choice.html'), choice);
151
+ const customizer = path.join(generated, 'customizer.html');
152
+ await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
153
+ const customizerPage = path.join(content, 'customize.md');
154
+ await write(customizerPage, `---\ntitle: Customize\nsidebar: false\nprevious: false\nnext: false\n---\n\n# Customize your theme\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
155
+ await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
156
+ const redirects = {};
157
+ for (const component of combined.components) {
158
+ const target = componentPath(component);
159
+ redirects[`/components/${component.group}/${component.tag}/`] = target;
160
+ if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
161
+ }
162
+ return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
163
+ }
164
+
165
+ /** Bundle validated theme restoration into MarkupKit's before-paint head hook. */
166
+ async function themeBootstrap(site) {
167
+ const { build } = await tool('vite', site.root);
168
+ const entry = path.join(site.generated, 'theme-bootstrap.mjs');
169
+ const baseline = site.config.recipe ?? { schemaVersion: 1, values: {} };
170
+ await writeFile(entry, `import { installDocumentationTheme } from './theme-session.mjs';\nimport systemCSS from '@threadlabs/looma/theme-system.css?inline';\ninstallDocumentationTheme(window, { base: ${JSON.stringify(site.base)}, baseline: ${JSON.stringify(baseline)}, systemCSS });\n`);
171
+ const bundled = await build({ root: site.root, configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry, name: 'LoomaDocumentationTheme', formats: ['iife'] } } });
172
+ return (Array.isArray(bundled) ? bundled[0] : bundled).output.find(file => file.type === 'chunk').code;
173
+ }
174
+
175
+ /** One plugin powers both the public consumer and local repositories. */
176
+ export function documentationPlugin(site) {
177
+ return {
178
+ name: 'looma-design-system',
179
+ generate: async () => ({ '/_looma/pastoral.svg': await readFile(own('pastoral.svg'), 'utf8'), '/_looma/portrait.svg': await readFile(own('portrait.svg'), 'utf8'), '/_looma/theme-system.css': await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8'),
180
+ ...Object.fromEntries(await Promise.all(guideAssets.map(async name => [`/_looma/${name}`, await readFile(own(`guides/assets/${name}`))]))),
181
+ }),
182
+ config: () => ({ css: [own('documentation.css')], imports: { '@threadlabs/looma/components/': new URL('components/', import.meta.resolve('@threadlabs/looma/tokens.css')).href } }),
183
+ };
184
+ }
185
+
186
+ export async function documentationOptions(site) {
187
+ const { siteOptions } = await tool('markupress', site.root);
188
+ const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
189
+ const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', headerNotice: 'Custom theme', sidebarNavigation: 'sections', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
190
+ const savedTheme = path.join(site.generated, 'saved-theme.css');
191
+ const recipe = site.config.recipe ?? defaultRecipe();
192
+ const overrides = await Promise.all((site.config.overrides ?? []).map(file => readFile(path.resolve(site.root, file), 'utf8')));
193
+ const localFonts = new Set(overrides.flatMap(css => [...css.matchAll(/@font-face\s*\{[^}]*font-family:\s*['"]([^'"]+)['"]/g)].map(match => match[1])));
194
+ const remoteFonts = Object.fromEntries(Object.entries(recipe.fonts ?? {}).filter(([, family]) => !localFonts.has(family)));
195
+ const imported = googleFontURL(recipe.fonts), remaining = googleFontURL(remoteFonts);
196
+ // A supplied face already serves the same family; avoid another render-blocking stylesheet.
197
+ const savedCSS = (site.config.themeCSSContents ?? themeCSS(recipe)).replace(`@import url("${imported}");`, remaining ? `@import url("${remaining}");` : '');
198
+ await writeFile(savedTheme, savedCSS);
199
+ const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
200
+ const plugins = options.plugins.map(plugin => plugin.name === 'markupress' ? { ...plugin,
201
+ pages: { ...plugin.pages, compile: (source, context) => plugin.pages.compile(source, { ...context,
202
+ // These package-owned illustrations are emitted by our plugin, outside the private build cache.
203
+ asset: file => {
204
+ const name = guideAssets.find(name => path.resolve(file) === path.join(site.content, '02.(Create your design system)', 'assets', name));
205
+ return name ? site.base + '_looma/' + name : context.asset(file);
206
+ },
207
+ }) },
208
+ config: async current => orderNavigation(await plugin.config(current), { nav: site.config.nav, base: site.base }),
209
+ } : plugin);
210
+ return { ...options, plugins: [...plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) },
211
+ // Markupress supplies its head script in its own config hook; compose after it.
212
+ { name: 'looma-browser-theme', config: async current => ({ headScript: [current.headScript, await themeBootstrap(site)].filter(Boolean).join('\n;\n') }) }] };
213
+ }
214
+
215
+ /** Bundle project theme styles once, shared by the page and every inline example. */
216
+ async function portableThemeStyles(site) {
217
+ const overrides = site.config.overrides ?? [];
218
+ if (!overrides.length) return [];
219
+ const { build } = await tool('vite', site.root);
220
+ const entry = path.join(site.generated, 'theme-entry.css');
221
+ await writeFile(entry, overrides.map(file => `@import ${JSON.stringify(path.resolve(site.root, file))};`).join('\n'));
222
+ const bundled = await build({ root: site.root, base: './', configFile: false, logLevel: 'silent',
223
+ build: { write: false, minify: false, cssMinify: false,
224
+ rollupOptions: { input: entry, output: { assetFileNames: 'theme-assets/[name]-[hash][extname]' } } } });
225
+ const styles = [];
226
+ for (const output of (Array.isArray(bundled) ? bundled[0] : bundled).output) {
227
+ if (output.type !== 'asset') continue;
228
+ const file = path.join(site.generated, output.fileName);
229
+ await mkdir(path.dirname(file), { recursive: true });
230
+ await writeFile(file, output.source);
231
+ if (file.endsWith('.css')) styles.push(file);
232
+ }
233
+ return styles;
234
+ }
235
+
236
+ /** Build references and examples together through MarkupKit. */
237
+ export async function buildDocumentation({ root = process.cwd(), config = {}, catalog } = {}) {
238
+ return withBuildLock(root, async () => {
239
+ const site = await prepareDocumentation({ root, config, catalog });
240
+ const { buildApplication } = await tool('@threadlabs/markupkit', site.root);
241
+ const built = await buildApplication(await documentationOptions(site));
242
+ return { ...built, site };
243
+ });
244
+ }
package/docs/store.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { defaultRecipe, themeCSS, validateRecipe } from './theme.mjs';
6
+
7
+ const missing = error => error.code === 'ENOENT';
8
+ const failure = (message, status = 409) => Object.assign(new Error(message), { status });
9
+ const digest = values => createHash('sha256').update(JSON.stringify(values)).digest('hex');
10
+ const contained = (root, file) => file.startsWith(root + path.sep);
11
+
12
+ /** A local writer owns only two explicitly configured theme files. */
13
+ export async function createThemeStore({ root, recipe = 'looma.theme.json', css = 'looma.theme.css' }) {
14
+ root = await realpath(path.resolve(root));
15
+ const destination = value => {
16
+ if (typeof value !== 'string' || !value || path.isAbsolute(value)) throw failure('Theme paths must be relative paths inside the repository.', 400);
17
+ const file = path.resolve(root, value);
18
+ if (!contained(root, file)) throw failure('Theme paths must stay inside the repository.', 400);
19
+ return file;
20
+ };
21
+ for (const value of [recipe, css]) {
22
+ if (typeof value === 'string' && value.split(/[\\/]/).some(part => ['.looma', '.git', 'node_modules'].includes(part))) throw failure('Theme outputs must stay outside generated files, Git metadata and node_modules.', 400);
23
+ }
24
+ const recipeFile = destination(recipe), cssFile = destination(css);
25
+ if (recipeFile === cssFile || !recipeFile.endsWith('.json') || !cssFile.endsWith('.css')) throw failure('Choose separate .json recipe and .css theme files.', 400);
26
+ const lockFile = destination('.looma/theme-write.lock');
27
+ const safe = async file => {
28
+ const pieces = path.relative(root, file).split(path.sep);
29
+ let cursor = root;
30
+ for (const piece of pieces) {
31
+ cursor = path.join(cursor, piece);
32
+ try { if ((await lstat(cursor)).isSymbolicLink()) throw failure(`Theme paths cannot traverse a symlink: ${path.relative(root, cursor)}.`, 400); }
33
+ catch (error) { if (!missing(error)) throw error; }
34
+ }
35
+ };
36
+ for (const file of [recipeFile, cssFile, lockFile]) await safe(file);
37
+ const bytes = async file => { await safe(file); try { return await readFile(file, 'utf8'); } catch (error) { if (missing(error)) return null; throw error; } };
38
+ const snapshot = async () => {
39
+ const [json, css] = await Promise.all([bytes(recipeFile), bytes(cssFile)]);
40
+ if (css !== null && !css.startsWith('/* Looma shared semantic theme. */')) throw failure('The theme CSS is not a generated Looma file. Choose a new output path and keep manual CSS in an overrides file.');
41
+ return { json, css, recipe: json === null ? defaultRecipe() : validateRecipe(JSON.parse(json)), revision: digest([json, css]) };
42
+ };
43
+ const read = async ({ includeCSS = false } = {}) => {
44
+ await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
45
+ let lock;
46
+ try { lock = await open(lockFile, 'wx', 0o600); }
47
+ catch (error) { if (error.code === 'EEXIST') throw failure('A theme editor holds .looma/theme-write.lock. Retry after its save. If the old editor stopped, close it before removing that lock and restarting.'); throw error; }
48
+ try { const { recipe, revision, css } = await snapshot(); return { recipe, revision, ...(includeCSS ? { css } : {}) }; }
49
+ finally { try { await lock.close(); } finally { await unlink(lockFile); } }
50
+ };
51
+ const stage = async (file, data) => {
52
+ await safe(file); await mkdir(path.dirname(file), { recursive: true }); await safe(file);
53
+ const temp = file + '.' + randomUUID() + '.tmp';
54
+ const handle = await open(temp, 'wx', 0o644);
55
+ try { await handle.writeFile(data); await handle.sync(); } catch (error) { await unlink(temp); throw error; } finally { await handle.close(); }
56
+ return temp;
57
+ };
58
+ const save = async input => {
59
+ const next = validateRecipe(input?.recipe);
60
+ if (typeof input?.revision !== 'string') throw failure('A theme revision is required. Reload before saving.', 400);
61
+ await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
62
+ let lock;
63
+ try { lock = await open(lockFile, 'wx', 0o600); }
64
+ catch (error) { if (error.code === 'EEXIST') throw failure('Another theme save is in progress. Reload before saving again.'); throw error; }
65
+ const temporary = new Set(); let original; let committedRecipe = false;
66
+ try {
67
+ original = await snapshot();
68
+ if (original.revision !== input.revision) throw failure('Theme files changed outside this editor. Reload before saving; your unsaved preview is still here.');
69
+ const systemCSS = await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8');
70
+ const nextJson = JSON.stringify(next, null, 2) + '\n';
71
+ const nextCSS = themeCSS(next, systemCSS);
72
+ const stagedRecipe = await stage(recipeFile, nextJson); temporary.add(stagedRecipe);
73
+ const stagedCSS = await stage(cssFile, nextCSS); temporary.add(stagedCSS);
74
+ if ((await snapshot()).revision !== original.revision) throw failure('Theme files changed while saving. Reload before saving again.');
75
+ await safe(recipeFile); await safe(cssFile);
76
+ await rename(stagedRecipe, recipeFile); temporary.delete(stagedRecipe); committedRecipe = true;
77
+ // CSS is the visible commit point: applications consume this complete file.
78
+ await rename(stagedCSS, cssFile); temporary.delete(stagedCSS);
79
+ return { recipe: next, revision: digest([nextJson, nextCSS]) };
80
+ } catch (error) {
81
+ if (committedRecipe) {
82
+ if (original.json === null) await unlink(recipeFile);
83
+ else { const restore = await stage(recipeFile, original.json); temporary.add(restore); await rename(restore, recipeFile); temporary.delete(restore); }
84
+ }
85
+ throw error;
86
+ } finally {
87
+ try { await Promise.all([...temporary].map(file => unlink(file).catch(error => { if (!missing(error)) throw error; }))); }
88
+ finally { try { await lock.close(); } finally { await unlink(lockFile); } }
89
+ }
90
+ };
91
+ return { root, recipeFile, cssFile, read, save };
92
+ }
@@ -0,0 +1,72 @@
1
+ import { themeCSS, validateRecipe } from './theme.mjs';
2
+
3
+ const identity = Symbol.for('looma.documentation.theme');
4
+ export const themeEvent = 'looma-documentation-theme';
5
+
6
+ /** Browser choices belong to one documentation base; repository writes stay explicit. */
7
+ export function createThemeSession(view, { base, baseline, systemCSS, historyLimit = 100 }) {
8
+ const key = `looma:documentation-theme:v1:${base}`;
9
+ let original = validateRecipe(baseline);
10
+ let selected, draft;
11
+ const past = [], future = [];
12
+ let portableCSS = '';
13
+ let storageAvailable = true;
14
+ const style = view.document.createElement('style');
15
+ style.dataset.loomaDocumentationTheme = '';
16
+ view.document.head.append(style);
17
+ const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
18
+ try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
19
+ const paint = () => {
20
+ const displayed = draft ?? selected;
21
+ portableCSS = displayed ? themeCSS(displayed, systemCSS) : '';
22
+ view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(displayed));
23
+ style.textContent = displayed ? themeCSS(displayed, systemCSS, { scope: 'documentation' }) : '';
24
+ view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: portableCSS } }));
25
+ };
26
+ const persist = () => {
27
+ try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
28
+ paint();
29
+ };
30
+ const remember = () => {
31
+ past.push(structuredClone(selected));
32
+ if (past.length > historyLimit) past.shift();
33
+ future.length = 0;
34
+ };
35
+ const session = {
36
+ get recipe() { return structuredClone(draft ?? selected ?? original); },
37
+ get hasOverride() { return selected !== undefined; },
38
+ get storageAvailable() { return storageAvailable; },
39
+ get css() { return portableCSS; },
40
+ get canUndo() { return past.length > 0; },
41
+ get canRedo() { return future.length > 0; },
42
+ preview(recipe) { draft = validateRecipe(recipe); paint(); },
43
+ commit() { if (draft) session.apply(draft); },
44
+ cancel() { draft = undefined; paint(); },
45
+ undo() { if (!past.length) return; future.push(structuredClone(selected)); selected = past.pop(); draft = undefined; persist(); },
46
+ redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = undefined; persist(); },
47
+ apply(recipe) {
48
+ const next = validateRecipe(recipe);
49
+ if (JSON.stringify(next) !== JSON.stringify(selected)) remember();
50
+ selected = next; draft = undefined; persist();
51
+ },
52
+ reset() { if (selected !== undefined) remember(); selected = draft = undefined; persist(); return structuredClone(original); },
53
+ baseline(recipe) { original = validateRecipe(recipe); },
54
+ };
55
+ view.addEventListener('storage', event => {
56
+ if (event.key !== key && event.key !== null) return;
57
+ const next = read(event.newValue);
58
+ if (event.newValue !== null && !next) return;
59
+ selected = next; draft = undefined; past.length = future.length = 0; paint();
60
+ });
61
+ view.addEventListener('markupress-header-notice-dismiss', () => session.reset());
62
+ paint();
63
+ return session;
64
+ }
65
+
66
+ /** The early head script and component modules share one session across client navigation. */
67
+ export function documentationTheme(view) { return view[identity]; }
68
+
69
+ export function installDocumentationTheme(view, config) {
70
+ if (view.parent !== view) return;
71
+ return view[identity] ??= createThemeSession(view, config);
72
+ }