@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.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/bin/looma-docs.mjs +3 -0
- package/docs/README.md +89 -0
- package/docs/card.html +79 -0
- package/docs/card.js +18 -0
- package/docs/catalog-view.js +24 -0
- package/docs/catalog-view.mjs +13 -0
- package/docs/catalog.mjs +135 -0
- package/docs/cli.mjs +26 -0
- package/docs/commands.mjs +94 -0
- package/docs/customizer-render.mjs +157 -0
- package/docs/customizer.js +494 -0
- package/docs/documentation.css +42 -0
- package/docs/example-invocations.mjs +63 -0
- package/docs/fonts.mjs +73 -0
- package/docs/framework-examples.d.mts +9 -0
- package/docs/framework-examples.mjs +225 -0
- package/docs/guides/01.theme-your-system.md +64 -0
- package/docs/guides/02.add-your-components.md +96 -0
- package/docs/guides/03.publish-your-system.md +71 -0
- package/docs/guides/04.theme-decisions.md +141 -0
- package/docs/guides/05.(Theme reference)/01.theme-colors.md +95 -0
- package/docs/guides/05.(Theme reference)/02.theme-typography.md +73 -0
- package/docs/guides/05.(Theme reference)/03.theme-spacing.md +103 -0
- package/docs/guides/05.(Theme reference)/04.theme-material.md +90 -0
- package/docs/guides/05.(Theme reference)/05.theme-motion.md +57 -0
- package/docs/guides/assets/fellowship-dark.png +0 -0
- package/docs/guides/assets/fellowship-light.png +0 -0
- package/docs/index.d.ts +59 -0
- package/docs/index.mjs +4 -0
- package/docs/local-server.mjs +65 -0
- package/docs/navigation.mjs +40 -0
- package/docs/palette.mjs +68 -0
- package/docs/pastoral.svg +54 -0
- package/docs/portrait.svg +15 -0
- package/docs/reference.html +1 -0
- package/docs/reference.js +36 -0
- package/docs/render.mjs +97 -0
- package/docs/site.mjs +244 -0
- package/docs/store.mjs +92 -0
- package/docs/theme-session.mjs +72 -0
- package/docs/theme.mjs +669 -0
- package/docs/value-editors.mjs +37 -0
- package/docs/workspace.mjs +32 -0
- package/package.json +53 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Threadlabs Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Looma docs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browse Looma and your authored components, customize a shared theme, and build a documentation site.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm add @threadlabs/looma
|
|
7
|
+
pnpm add -D @threadlabs/looma-docs
|
|
8
|
+
pnpm exec looma-docs init --title "Your design system"
|
|
9
|
+
pnpm exec looma-docs dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The documentation package owns Markupress, MarkupKit, Vite and the editor preview dependencies. Their tested minimum versions are declared in package metadata; your lockfile records the installed versions.
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Create your design system
|
|
3
|
+
navigation: overview
|
|
4
|
+
order: 0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<div data-looma-guide>
|
|
8
|
+
|
|
9
|
+
# Create your design system
|
|
10
|
+
|
|
11
|
+
Give your product its own colors, type, and component library. Looma gives you a local documentation site where your components and theme stay together.
|
|
12
|
+
|
|
13
|
+
<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">
|
|
14
|
+
<link rel="component" href="@threadlabs/looma/components/ui-card/ui-card.html">
|
|
15
|
+
|
|
16
|
+
<ui-grid columns="2" min="lg" gap="xl">
|
|
17
|
+
<section class="looma-guide-start">
|
|
18
|
+
|
|
19
|
+
## 1. Start your local site
|
|
20
|
+
|
|
21
|
+
In a project with a `package.json`, run:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pnpm add @threadlabs/looma
|
|
25
|
+
pnpm add -D @threadlabs/looma-docs
|
|
26
|
+
pnpm exec looma-docs init \
|
|
27
|
+
--title "Fellowship UI"
|
|
28
|
+
pnpm exec looma-docs dev
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open the address printed in your terminal. You can browse Looma's components and choose **Customize** to start shaping your theme.
|
|
32
|
+
|
|
33
|
+
</section>
|
|
34
|
+
<div>
|
|
35
|
+
<ui-card padding="sm">
|
|
36
|
+
<figure class="looma-guide-figure">
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
<figcaption>Fellowship UI · A custom theme and project card built with Looma.</figcaption>
|
|
41
|
+
</figure>
|
|
42
|
+
</ui-card>
|
|
43
|
+
|
|
44
|
+
[Explore the example source](https://github.com/threadlabs-studio/looma/tree/main/examples/fellowship-ui)
|
|
45
|
+
|
|
46
|
+
</div>
|
|
47
|
+
</ui-grid>
|
|
48
|
+
|
|
49
|
+
## 2. Make it yours
|
|
50
|
+
|
|
51
|
+
Choose a starting theme, then adjust color, typography, spacing, and material. Changes appear across the site and its live component examples as you edit.
|
|
52
|
+
|
|
53
|
+
[Theme your system](/theme-your-system/) walks through choosing an identity, checking light and dark, and saving the result.
|
|
54
|
+
|
|
55
|
+
## 3. Use it in your application
|
|
56
|
+
|
|
57
|
+
Initialization creates your configuration and two theme files:
|
|
58
|
+
|
|
59
|
+
::: table
|
|
60
|
+
|
|
61
|
+
- File: `looma.docs.json`
|
|
62
|
+
Purpose: Your site name, components, and documentation.
|
|
63
|
+
|
|
64
|
+
- File: `looma.theme.json`
|
|
65
|
+
Purpose: Editable theme decisions.
|
|
66
|
+
|
|
67
|
+
- File: `looma.theme.css`
|
|
68
|
+
Purpose: The generated CSS your application loads.
|
|
69
|
+
|
|
70
|
+
:::
|
|
71
|
+
|
|
72
|
+
Import the generated theme after Looma's styles in your application entry:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import "@threadlabs/looma/tokens.css";
|
|
76
|
+
import "@threadlabs/looma/theme-light.css";
|
|
77
|
+
import "@threadlabs/looma/theme-dark.css";
|
|
78
|
+
import "./looma.theme.css";
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Save from **Customize → Export & save** to update the local theme files. Commit them with your application and add `.looma/` to your ignore file.
|
|
82
|
+
|
|
83
|
+
## Keep building
|
|
84
|
+
|
|
85
|
+
- [Add your components](/add-your-components/): put your HTML Next definitions, examples, and API reference beside Looma's.
|
|
86
|
+
- [Publish your system](/publish-your-system/): add your branding and share a static documentation site.
|
|
87
|
+
- [Theme decisions](/theme-decisions/): look up the rules behind color, type, material, and motion.
|
|
88
|
+
|
|
89
|
+
</div>
|
package/docs/card.html
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">
|
|
2
|
+
<link rel="component" href="@threadlabs/looma/components/ui-form-field/ui-form-field.html">
|
|
3
|
+
<link rel="component" href="@threadlabs/looma/components/ui-input/ui-input.html">
|
|
4
|
+
<link rel="component" href="@threadlabs/looma/components/ui-button/ui-button.html">
|
|
5
|
+
<template component="looma-example-card" module="./card.js">
|
|
6
|
+
<defs>
|
|
7
|
+
<prop name="identity" type="string" default="">Stable catalog example identity.</prop>
|
|
8
|
+
<prop name="title" type="string" default="Example">The documented component name.</prop>
|
|
9
|
+
<prop name="description" type="string" default="">The component's authored description.</prop>
|
|
10
|
+
<prop name="exampleTitle" type="string" default="">The selected authored example.</prop>
|
|
11
|
+
<prop name="previewOnly" type="boolean" default="false">Show only the live example, for compact theme samplers whose host supplies the heading.</prop>
|
|
12
|
+
<prop name="reference" type="boolean" default="false">Use the spacious full-reference example presentation.</prop>
|
|
13
|
+
<prop name="resizable" type="boolean" default="false">Expose a width control for responsive examples.</prop>
|
|
14
|
+
<prop name="category" type="string" default="forms">The shared category identity.</prop>
|
|
15
|
+
<prop name="tag" type="string" default="">The component's authored tag.</prop>
|
|
16
|
+
<prop name="packageName" type="string" default="">The package that owns this component.</prop>
|
|
17
|
+
<prop name="nativeRoot" type="string" default="">The component's semantic native root.</prop>
|
|
18
|
+
<prop name="href" type="string" default="">The component reference page.</prop>
|
|
19
|
+
<prop name="removable" type="boolean" default="false">Offer removal when this card is selected in the customizer.</prop>
|
|
20
|
+
</defs>
|
|
21
|
+
<article from:data-example-card="$identity" from:data-component-card="$tag" from:data-category="$category">
|
|
22
|
+
<header $if="$reference" class="example-heading"><slot name="heading"><h3>{$exampleTitle}</h3><p>{$description}</p></slot></header>
|
|
23
|
+
<div class="preview"><div class="stage" $ref="stage"><slot name="preview"></slot></div></div>
|
|
24
|
+
<section $if="$reference" class="example-code"><h4>Code</h4><slot name="code"></slot></section>
|
|
25
|
+
<div $if="$resizable" class="preview-width"> <ui-form-field><label slot="label">Preview width</label><span slot="help">Explore the example at a narrower width.</span><ui-input $ref="width" type="number" min="240" max="1600" value="720" aria-label="Preview width in pixels" size="sm"></ui-input></ui-form-field></div>
|
|
26
|
+
<div $if="not $reference and not $previewOnly" class="body"><ui-stack gap="xs">
|
|
27
|
+
<div class="meta"><span>{$category}</span><code>{$nativeRoot}</code></div>
|
|
28
|
+
<h3><a class="destination" from:href="$href">{$title}</a></h3>
|
|
29
|
+
<p class="description">{$description}</p>
|
|
30
|
+
<p class="owner" $if="$packageName">{$packageName}</p>
|
|
31
|
+
<p class="example-caption" $if="$exampleTitle">{$exampleTitle}</p>
|
|
32
|
+
|
|
33
|
+
<footer><span $if="$href" class="view-link">View component</span><div class="secondary-actions"><ui-button $if="$removable" $ref="remove" variant="ghost" size="sm">Remove</ui-button><slot name="actions"></slot></div></footer>
|
|
34
|
+
</ui-stack></div>
|
|
35
|
+
</article>
|
|
36
|
+
<style>
|
|
37
|
+
/* The existing documentation catalog card, shared by the directory, reference and customizer. */
|
|
38
|
+
:host {
|
|
39
|
+
--_card-accent: var(--ui-accent);
|
|
40
|
+
position: relative;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
background: var(--ui-surface-raised);
|
|
43
|
+
border: var(--ui-border-width) solid var(--ui-border);
|
|
44
|
+
border-radius: var(--ui-radius-lg);
|
|
45
|
+
box-shadow: var(--ui-shadow-sm);
|
|
46
|
+
}
|
|
47
|
+
:host([category="display"]) { --_card-accent: var(--ui-success); }
|
|
48
|
+
:host([category="overlay"]) { --_card-accent: var(--ui-danger); }
|
|
49
|
+
:host([category="layout"]) { --_card-accent: var(--ui-info); }
|
|
50
|
+
:host([category="editor"]) { --_card-accent: var(--ui-warning); }
|
|
51
|
+
.example-heading { padding: var(--ui-space-5) !important; border-block-end: var(--ui-border-width) solid var(--ui-border); }
|
|
52
|
+
.example-code { padding: var(--ui-space-5) !important; border-block-start: var(--ui-border-width) solid var(--ui-border); }
|
|
53
|
+
.example-code h4 { margin: 0 0 var(--ui-space-3) !important; font-size: var(--ui-font-size-md); }
|
|
54
|
+
.example-heading p { margin: var(--ui-space-2) 0 0 !important; color: var(--ui-text-secondary); font-size: var(--ui-font-size-sm); }
|
|
55
|
+
:host([reference]) .preview { min-block-size: 11rem; display: grid !important; align-items: center !important; border-block-end: 0; }
|
|
56
|
+
:host([reference]) .preview::before { display: none; }
|
|
57
|
+
.preview { position: relative; min-block-size: 13rem; overflow: hidden; border-block-end: var(--ui-border-width) solid var(--ui-border); background: var(--ui-surface-raised); }
|
|
58
|
+
.preview::before { position: absolute; inset-block-start: 0; inset-inline-start: 0; inline-size: 3.5rem; block-size: 3px; background: var(--_card-accent); content: ""; z-index: 1; }
|
|
59
|
+
.stage { inline-size: var(--_preview-width, 100%); max-inline-size: 100%; min-inline-size: 0; }
|
|
60
|
+
footer { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: var(--ui-space-2) !important; }
|
|
61
|
+
.preview-width { padding: var(--ui-space-4) !important; }
|
|
62
|
+
.body { position: relative; isolation: isolate; padding: var(--ui-space-5) !important; background: var(--ui-surface-raised); transition: background-color var(--ui-motion-fast) var(--ui-motion-ease); }
|
|
63
|
+
.body:has(.destination[href]:hover), .body:has(.destination[href]:focus-visible) { background: var(--ui-accent-subtle); }
|
|
64
|
+
.body:has(.destination[href]:active) { background: color-mix(in srgb, var(--ui-accent) 16%, var(--ui-surface-raised)); }
|
|
65
|
+
.destination[href]::after { position: absolute; inset: 0; z-index: 1; content: ""; cursor: pointer; }
|
|
66
|
+
.destination:focus-visible { outline: none; }
|
|
67
|
+
.destination:focus-visible::after { outline: var(--ui-focus-width) solid var(--ui-focus-ring); outline-offset: calc(-1 * var(--ui-focus-width)); }
|
|
68
|
+
.view-link { color: var(--ui-link-color); font-size: var(--ui-font-size-sm); font-weight: var(--ui-font-semibold); text-decoration: underline; text-underline-offset: .2em; }
|
|
69
|
+
.secondary-actions { position: relative; z-index: 2; display: flex !important; align-items: center !important; gap: var(--ui-space-2) !important; }
|
|
70
|
+
.meta { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: var(--ui-space-2) !important; margin: 0 !important; color: var(--ui-text-secondary); font-size: var(--ui-font-size-xs); font-weight: var(--ui-font-bold); letter-spacing: .09em; text-transform: uppercase; }
|
|
71
|
+
.meta code { padding: 0 !important; background: transparent; color: inherit; text-transform: none; }
|
|
72
|
+
h3 { margin: 0 !important; padding: 0 !important; font-size: var(--ui-font-size-lg); letter-spacing: -.035em; }
|
|
73
|
+
h3 a { color: var(--ui-text); text-decoration: none; }
|
|
74
|
+
.description { margin: 0 !important; color: var(--ui-text-secondary); font-size: var(--ui-font-size-sm); line-height: var(--ui-line-height); }
|
|
75
|
+
.owner { margin: 0 !important; color: var(--ui-text-secondary); font-size: var(--ui-font-size-xs); overflow-wrap: anywhere; }
|
|
76
|
+
.example-caption { margin: 0 !important; color: var(--ui-text-secondary); font-size: var(--ui-font-size-xs); }
|
|
77
|
+
@media (prefers-reduced-motion: reduce) { .body { transition: none; } }
|
|
78
|
+
</style>
|
|
79
|
+
</template>
|
package/docs/card.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { exampleBehaviors } from './example-behaviors.js';
|
|
2
|
+
/** Cards share their document's theme and runtime; width controls size only this stage. */
|
|
3
|
+
export function connect(host) {
|
|
4
|
+
const { stage, width, remove } = host.refs;
|
|
5
|
+
const stopExample = exampleBehaviors[host.props.identity.value]?.(host.element.querySelector('[data-preview]'));
|
|
6
|
+
const removeCard = () => host.element.dispatchEvent(new CustomEvent('looma-example-remove', { bubbles: true, detail: { id: host.props.identity.value } }));
|
|
7
|
+
const resize = event => {
|
|
8
|
+
const value = Number(event.detail?.value ?? event.target.value);
|
|
9
|
+
if (value >= 240 && value <= 1600) stage.style.setProperty('--_preview-width', `${value}px`);
|
|
10
|
+
};
|
|
11
|
+
width?.addEventListener('input', resize);
|
|
12
|
+
remove?.addEventListener('click', removeCard);
|
|
13
|
+
return () => {
|
|
14
|
+
stopExample?.();
|
|
15
|
+
width?.removeEventListener('input', resize);
|
|
16
|
+
remove?.removeEventListener('click', removeCard);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Native catalog entries own their visibility; component previews keep their own layout. */
|
|
2
|
+
export function connect(host) {
|
|
3
|
+
const { query, category, count, filters } = host.refs;
|
|
4
|
+
const groups = [...host.element.querySelectorAll('[data-catalog-group]')];
|
|
5
|
+
const items = [...host.element.querySelectorAll('[data-catalog-search]')];
|
|
6
|
+
query.value = '';
|
|
7
|
+
const filter = () => {
|
|
8
|
+
const words = query.value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
let visible = 0;
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
item.hidden = !words.every(word => item.dataset.catalogSearch.toLocaleLowerCase().includes(word));
|
|
12
|
+
if (!item.hidden) visible++;
|
|
13
|
+
}
|
|
14
|
+
for (const group of groups) group.hidden = ![...group.querySelectorAll('[data-catalog-search]')].some(item => !item.hidden);
|
|
15
|
+
count.textContent = visible === 0 ? 'No components match. Try another name or section.' : `${visible} component${visible === 1 ? '' : 's'}`;
|
|
16
|
+
};
|
|
17
|
+
const chooseCategory = () => {
|
|
18
|
+
const destination = new URL(category.selectedOptions[0].dataset.href, location.href);
|
|
19
|
+
location.assign(destination.href);
|
|
20
|
+
};
|
|
21
|
+
query.addEventListener('input', filter); category.addEventListener('change', chooseCategory); filter();
|
|
22
|
+
filters.hidden = false;
|
|
23
|
+
return () => { query.removeEventListener('input', filter); category.removeEventListener('change', chooseCategory); };
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { escapeHtml as escape } from './catalog.mjs';
|
|
2
|
+
|
|
3
|
+
/** Catalog filtering wraps the same server-rendered documentation cards. */
|
|
4
|
+
export function catalogResource(groups, base = '/') {
|
|
5
|
+
return `<link rel="component" href="@threadlabs/looma/components/ui-form-field/ui-form-field.html">
|
|
6
|
+
<link rel="component" href="@threadlabs/looma/components/ui-input/ui-input.html">
|
|
7
|
+
<link rel="component" href="@threadlabs/looma/components/ui-select/ui-select.html">
|
|
8
|
+
<link rel="component" href="@threadlabs/looma/components/ui-cluster/ui-cluster.html">
|
|
9
|
+
<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">
|
|
10
|
+
<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">
|
|
11
|
+
<template component="looma-catalog" module="./catalog-view.js"><defs><prop name="category" type="string" default="">The category represented by this gallery.</prop><prop name="total" type="number" default="0">Number of cards before text filtering.</prop></defs>
|
|
12
|
+
<ui-stack gap="xl"><div $ref="filters" hidden><ui-grid columns="2" min="md"><ui-form-field><label slot="label" for="catalog-query">Filter components</label><ui-input id="catalog-query" $ref="query" type="search" placeholder="Name, tag or purpose"></ui-input></ui-form-field><ui-cluster align="end"><ui-form-field><label slot="label" for="catalog-category">Section</label><ui-select id="catalog-category" $ref="category"><option value="" data-href="${escape(base)}components/" from:selected="$category = ''">All sections</option>${groups.map(group => `<option value="${escape(group.id)}" data-href="${escape(base)}components/${escape(group.id)}/" from:selected="${escape(`$category = ${JSON.stringify(group.id)}`)}">${escape(group.label)}</option>`).join('')}</ui-select></ui-form-field></ui-cluster></ui-grid></div><p $ref="count" role="status" aria-live="polite">{$total} components</p><slot></slot></ui-stack></template>`;
|
|
13
|
+
}
|
package/docs/catalog.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export const catalogVersion = 1;
|
|
6
|
+
export const escapeHtml = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
7
|
+
export const titleOf = tag => tag.replace(/^ui-/, '').split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
|
|
8
|
+
export async function readJson(file, fallback) {
|
|
9
|
+
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
10
|
+
catch (error) { if (error.code === 'ENOENT' && fallback !== undefined) return fallback; throw error; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Authored examples are data during discovery; controllers are never evaluated. */
|
|
14
|
+
export async function readExamples(directory, componentId) {
|
|
15
|
+
let files;
|
|
16
|
+
try { files = (await readdir(directory)).sort(); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
17
|
+
return Promise.all(files.filter(file => /^[^.]+\.html$/.test(file)).map(async file => {
|
|
18
|
+
const name = file.slice(0, -5);
|
|
19
|
+
const source = await readFile(path.join(directory, file), 'utf8');
|
|
20
|
+
const meta = /^<!--\r?\n([\s\S]*?)\r?\n-->\s*/.exec(source);
|
|
21
|
+
if (!meta) throw new Error(`${path.join(directory, file)}: example requires title/description metadata.`);
|
|
22
|
+
const fields = Object.fromEntries(meta[1].split(/\r?\n/).map(line => {
|
|
23
|
+
const at = line.indexOf(':'); return at < 0 ? [] : [line.slice(0, at).trim(), line.slice(at + 1).trim()];
|
|
24
|
+
}).filter(pair => pair.length));
|
|
25
|
+
const frameworks = {};
|
|
26
|
+
for (const [target, extension, language] of [['html-next', '.snippet.html', 'html'], ['vue', '.vue', 'vue'], ['react', '.tsx', 'tsx'], ['svelte', '.svelte', 'svelte']]) {
|
|
27
|
+
if (files.includes(name + extension)) frameworks[target] = { language, code: await readFile(path.join(directory, name + extension), 'utf8') };
|
|
28
|
+
}
|
|
29
|
+
const behavior = files.includes(name + '.behavior.ts') ? path.join(directory, name + '.behavior.ts') : files.includes(name + '.behavior.js') ? path.join(directory, name + '.behavior.js') : undefined;
|
|
30
|
+
return { id: `${componentId}/${name}`, name, title: fields.title ?? name, description: fields.description ?? '', markup: source.slice(meta[0].length).trim(), resizable: fields.preview === 'resizable', ...(fields.preview === 'stretch' ? { stretch: true } : {}), bindings: fields.bind ?? '', frameworks, ...(behavior ? { behavior } : {}) };
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resources(directory) {
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
37
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'examples') continue;
|
|
38
|
+
const file = path.join(directory, entry.name);
|
|
39
|
+
if (entry.isDirectory()) result.push(...await resources(file));
|
|
40
|
+
else if (entry.name.endsWith('.html')) result.push(file);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Discover only explicitly selected roots through HTML Next's public parser. */
|
|
46
|
+
export async function discoverComponents({ root, directory, packageName, groups = [] }) {
|
|
47
|
+
if (!packageName || !directory) throw new Error('A discovery root requires packageName and directory.');
|
|
48
|
+
const { parseComponentResource, typeScriptType, normalizeType, angularHostTag } = await import('@nextwebwg/html-next');
|
|
49
|
+
const components = [];
|
|
50
|
+
const definitions = new Map();
|
|
51
|
+
for (const source of await resources(path.resolve(root, directory))) {
|
|
52
|
+
const html = await readFile(source, 'utf8');
|
|
53
|
+
if (!/<template\b[^>]*\bcomponent=/.test(html)) continue;
|
|
54
|
+
const parsed = parseComponentResource(html, source);
|
|
55
|
+
const metadata = await readJson(path.join(path.dirname(source), 'docs.json'), {});
|
|
56
|
+
for (const definition of parsed.definitions) {
|
|
57
|
+
const contract = definition.contract;
|
|
58
|
+
definitions.set(contract.tag, definition);
|
|
59
|
+
const docs = metadata.components?.[contract.tag] ?? metadata;
|
|
60
|
+
const id = `${packageName}/${contract.tag}`;
|
|
61
|
+
const properties = Object.entries(contract.props).map(([name, prop]) => ({ name, type: typeScriptType(prop.type), description: prop.description ?? '', ...(Object.hasOwn(prop, 'default') ? { default: prop.default } : {}), ...(prop.values ? { options: prop.values } : {}) }));
|
|
62
|
+
const attributes = properties.map(prop => ({ ...prop, property: prop.name, name: prop.name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`), valueType: normalizeType(contract.props[prop.name].type) }));
|
|
63
|
+
const events = (definition.declarations ?? []).filter(item => item.kind === 'event').map(event => ({ name: event.name, detailType: event.shape ? typeScriptType(event.shape) : String(event.type), detailDocs: docs.events?.[event.name] ?? '' }));
|
|
64
|
+
const slots = (definition.slots ?? []).map(slot => ({ name: slot.name ?? 'default', description: docs.slots?.[slot.name ?? 'default'] ?? '' }));
|
|
65
|
+
const tokens = [...new Set([...definition.css.matchAll(/var\(\s*(--[\w-]+)/g)].map(match => match[1]))].sort();
|
|
66
|
+
components.push({ id, package: packageName, tag: contract.tag, title: docs.title ?? titleOf(contract.tag), description: docs.description ?? contract.summary ?? '', ...(docs.summary ? { summary: docs.summary } : {}), group: docs.group ?? 'project', ...(docs.navigationParent ? { navigationParent: docs.navigationParent } : {}), root: contract.nativeElement, source, resource: source, importSource: './' + path.relative(root, source).split(path.sep).join('/'), targets: ['html-next', 'vue', 'react', 'preact', 'svelte', 'angular'], attributes, properties, events, slots, designTokens: { sources: [path.relative(root, source)], component: [], shared: tokens.map(name => ({ name, fallbacks: [] })) }, examples: await readExamples(path.join(path.dirname(source), 'examples'), id) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const component of components) component.angularHost = angularHostTag(definitions.get(component.tag), definitions);
|
|
70
|
+
const categories = components.some(component => component.group === 'project') && !groups.some(group => group.id === 'project') ? [...groups, { id: 'project', label: 'Project components' }] : groups;
|
|
71
|
+
return { schemaVersion: catalogVersion, package: packageName, groups: categories, components };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Merge deterministically, reporting both owners instead of silently shadowing resources. */
|
|
75
|
+
export function mergeCatalogs(catalogs, { categories = {} } = {}) {
|
|
76
|
+
const resourceByTag = new Map();
|
|
77
|
+
const exampleOwners = new Map();
|
|
78
|
+
const groups = new Map(); const byId = new Map(); const byTag = new Map();
|
|
79
|
+
for (const catalog of catalogs) {
|
|
80
|
+
if (catalog.schemaVersion !== catalogVersion) throw new Error(`Unsupported documentation catalog version ${catalog.schemaVersion} for ${catalog.package}.`);
|
|
81
|
+
for (const group of catalog.groups ?? []) {
|
|
82
|
+
const mapped = categories[group.id] ?? group.id;
|
|
83
|
+
if (typeof mapped !== 'string' || !/^[a-z][a-z0-9-]*$/.test(mapped)) throw new Error(`Invalid category identity ${JSON.stringify(mapped)} in ${catalog.package}.`);
|
|
84
|
+
const previous = groups.get(mapped);
|
|
85
|
+
if (previous && previous.label !== group.label && mapped === group.id) throw new Error(`Conflicting category ${mapped}: ${previous.label} and ${group.label}. Configure a category mapping explicitly.`);
|
|
86
|
+
if (!previous) groups.set(mapped, { ...group, id: mapped });
|
|
87
|
+
}
|
|
88
|
+
for (const entry of catalog.components) {
|
|
89
|
+
// HTML Next's public component-tag grammar also keeps generated filenames inside their tree.
|
|
90
|
+
if (typeof entry.tag !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(entry.tag)) throw new Error(`Invalid component tag ${JSON.stringify(entry.tag)} in ${catalog.package}.`);
|
|
91
|
+
if (typeof entry.id !== 'string' || !entry.id) throw new Error(`Missing component identity for ${entry.tag} in ${catalog.package}.`);
|
|
92
|
+
const component = { ...entry, group: categories[entry.group] ?? entry.group };
|
|
93
|
+
const previous = byTag.get(component.tag) ?? byId.get(component.id);
|
|
94
|
+
if (previous) throw new Error(`Duplicate component ${component.tag}: ${previous.id} (${previous.source ?? previous.resource}) and ${component.id} (${component.source ?? component.resource}).`);
|
|
95
|
+
byId.set(component.id, component); byTag.set(component.tag, component);
|
|
96
|
+
if (!Array.isArray(component.examples)) throw new Error(`Component ${component.id} requires an examples array.`);
|
|
97
|
+
for (const example of component.examples) {
|
|
98
|
+
if (typeof example.id !== 'string' || !example.id || typeof example.markup !== 'string') throw new Error(`Invalid example identity or markup in ${component.id}.`);
|
|
99
|
+
if (!example.id.startsWith(component.id + '/')) throw new Error(`Example ${example.id} must belong to ${component.id}.`);
|
|
100
|
+
const previous = exampleOwners.get(example.id);
|
|
101
|
+
if (previous) throw new Error(`Duplicate example ${example.id}: ${previous} and ${component.id}.`);
|
|
102
|
+
exampleOwners.set(example.id, component.id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const entry of [...catalog.resources ?? [], ...catalog.components]) {
|
|
106
|
+
const previous = resourceByTag.get(entry.tag);
|
|
107
|
+
if (previous && (previous.package !== entry.package || previous.resource !== entry.resource)) throw new Error(`Conflicting resource ${entry.tag}: ${previous.resource} and ${entry.resource}.`);
|
|
108
|
+
resourceByTag.set(entry.tag, { tag: entry.tag, package: entry.package, resource: entry.resource,
|
|
109
|
+
root: entry.root ?? previous?.root, angularHost: entry.angularHost ?? previous?.angularHost });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const ownerOf = (component, trail = []) => {
|
|
113
|
+
if (trail.includes(component.id)) throw new Error(`Compound parent cycle: ${[...trail, component.id].join(' → ')}.`);
|
|
114
|
+
if (!component.navigationParent) return component;
|
|
115
|
+
const parent = byId.get(component.navigationParent) ?? byTag.get(component.navigationParent);
|
|
116
|
+
if (!parent) throw new Error(`Missing compound parent ${component.navigationParent} for ${component.id}.`);
|
|
117
|
+
return ownerOf(parent, [...trail, component.id]);
|
|
118
|
+
};
|
|
119
|
+
for (const component of byId.values()) {
|
|
120
|
+
if (component.navigationParent) {
|
|
121
|
+
const owner = ownerOf(component);
|
|
122
|
+
component.navigationParent = owner.tag;
|
|
123
|
+
component.group = owner.group;
|
|
124
|
+
}
|
|
125
|
+
if (!groups.has(component.group)) throw new Error(`Unknown category ${component.group} for ${component.id}. Declare its category or map it to an existing one.`);
|
|
126
|
+
}
|
|
127
|
+
return { schemaVersion: catalogVersion, resources: [...resourceByTag.values()], groups: [...groups.values()], components: [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read a published catalog without importing the optional documentation server. */
|
|
131
|
+
export async function readCatalog(specifier = new URL(import.meta.resolve('@threadlabs/looma/docs/catalog.json'))) {
|
|
132
|
+
const catalog = await readJson(specifier instanceof URL ? fileURLToPath(specifier) : specifier);
|
|
133
|
+
if (catalog.schemaVersion !== catalogVersion) throw new Error(`Unsupported documentation catalog version ${catalog.schemaVersion}.`);
|
|
134
|
+
return catalog;
|
|
135
|
+
}
|
package/docs/cli.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation } from './commands.mjs';
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { root: { type: 'string' }, port: { type: 'string' }, title: { type: 'string' }, help: { type: 'boolean', short: 'h' } } });
|
|
7
|
+
const [command = 'help'] = positionals;
|
|
8
|
+
if (values.help || command === 'help') {
|
|
9
|
+
console.log('looma-docs init | dev | build | preview\n\n --root <project> Project directory\n --port <number> Local server port (default 3000)\n --title <name> Documentation title for init\n\nInstall @threadlabs/looma-docs as a development dependency.');
|
|
10
|
+
} else {
|
|
11
|
+
const options = { ...(values.root ? { root: values.root } : {}), ...(values.title ? { title: values.title } : {}) };
|
|
12
|
+
if (values.port) { const port = Number(values.port); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Port must be an integer from 0 to 65535.'); options.port = port; }
|
|
13
|
+
if (command === 'init') {
|
|
14
|
+
const result = await initializeDocumentation(options);
|
|
15
|
+
console.log(`Created ${result.files.join(', ')}.\nImport ${result.applicationImport} after Looma styles in your application.\nRun looma-docs dev to weave your design system.`);
|
|
16
|
+
} else if (command === 'build') {
|
|
17
|
+
const built = await buildProjectDocumentation(options); console.log(`Built ${built.routes.length} documentation routes in ${built.outDir}.`);
|
|
18
|
+
} else if (command === 'dev' || command === 'preview') {
|
|
19
|
+
const server = await (command === 'dev' ? devProjectDocumentation : previewProjectDocumentation)(options);
|
|
20
|
+
console.log(server.url);
|
|
21
|
+
let closing = false;
|
|
22
|
+
const close = async () => { if (closing) return; closing = true; await server.close(); process.exitCode = 0; };
|
|
23
|
+
process.once('SIGINT', close); process.once('SIGTERM', close);
|
|
24
|
+
} else throw new Error(`Unknown documentation command ${command}. Use looma-docs --help.`);
|
|
25
|
+
}
|
|
26
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { lstat, open, readFile, realpath, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { buildDocumentation, tool } from './site.mjs';
|
|
6
|
+
import { createThemeStore } from './store.mjs';
|
|
7
|
+
import { serveLocalDocumentation } from './local-server.mjs';
|
|
8
|
+
import { defaultRecipe, themeCSS } from './theme.mjs';
|
|
9
|
+
|
|
10
|
+
export const configName = 'looma.docs.json';
|
|
11
|
+
export async function readConfiguration(root = process.cwd()) {
|
|
12
|
+
root = await realpath(path.resolve(root));
|
|
13
|
+
try { return { root, config: JSON.parse(await readFile(path.join(root, configName), 'utf8')) }; }
|
|
14
|
+
catch (error) { if (error.code === 'ENOENT') throw new Error(`No ${configName} in this project. Run looma-docs init first.`); throw error; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Initialization creates explicit owned files, leaving the application's imports to its author. */
|
|
18
|
+
export async function initializeDocumentation({ root = process.cwd(), title = 'Your design system' } = {}) {
|
|
19
|
+
root = await realpath(path.resolve(root));
|
|
20
|
+
const config = { title, base: '/', outDir: '.looma/site', theme: { recipe: 'looma.theme.json', css: 'looma.theme.css' }, components: [] };
|
|
21
|
+
const recipe = defaultRecipe();
|
|
22
|
+
const system = await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8');
|
|
23
|
+
const files = new Map([[configName, JSON.stringify(config, null, 2) + '\n'], [config.theme.recipe, JSON.stringify(recipe, null, 2) + '\n'], [config.theme.css, themeCSS(recipe, system)]]);
|
|
24
|
+
for (const name of files.keys()) {
|
|
25
|
+
try { await lstat(path.join(root, name)); throw new Error(`${name} already exists. Initialization will not replace it.`); }
|
|
26
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
27
|
+
}
|
|
28
|
+
const created = [];
|
|
29
|
+
try {
|
|
30
|
+
for (const [name, source] of files) {
|
|
31
|
+
const file = path.join(root, name); const handle = await open(file, 'wx', 0o644); created.push(file);
|
|
32
|
+
try { await handle.writeFile(source); } finally { await handle.close(); }
|
|
33
|
+
}
|
|
34
|
+
} catch (error) { await Promise.all(created.map(file => unlink(file))); throw error; }
|
|
35
|
+
return { config, files: [...files.keys()], applicationImport: './' + config.theme.css };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function themeConfiguration(root, config, local = false) {
|
|
39
|
+
if (!config.theme && !local) return config;
|
|
40
|
+
const store = await createThemeStore({ root, ...config.theme });
|
|
41
|
+
const current = await store.read({ includeCSS: true });
|
|
42
|
+
return { ...config, recipe: current.recipe, revision: current.revision, ...(current.css !== null ? { themeCSSContents: current.css } : {}), local };
|
|
43
|
+
}
|
|
44
|
+
export async function buildProjectDocumentation({ root = process.cwd(), config, catalog } = {}) {
|
|
45
|
+
if (!config) ({ root, config } = await readConfiguration(root));
|
|
46
|
+
return buildDocumentation({ root, config: await themeConfiguration(root, config), catalog });
|
|
47
|
+
}
|
|
48
|
+
export async function previewProjectDocumentation({ root = process.cwd(), config, port = 3000 } = {}) {
|
|
49
|
+
if (!config) ({ root, config } = await readConfiguration(root));
|
|
50
|
+
const { previewApplication } = await tool('@threadlabs/markupkit');
|
|
51
|
+
return previewApplication({ root, outDir: config.outDir ?? '.looma/site', base: config.base ?? '/', host: '127.0.0.1', port });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** MarkupKit serves the build; this local-only gateway adds the constrained repository writer. */
|
|
55
|
+
export async function devProjectDocumentation({ root = process.cwd(), config, catalog, port = 3000, onError = console.error } = {}) {
|
|
56
|
+
const configured = Boolean(config);
|
|
57
|
+
if (!config) ({ root, config } = await readConfiguration(root));
|
|
58
|
+
const store = await createThemeStore({ root, ...config.theme });
|
|
59
|
+
let closed = false, dirty = true, building, timer;
|
|
60
|
+
const rebuild = () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
if (building) return building;
|
|
63
|
+
building = (async () => {
|
|
64
|
+
while (dirty && !closed) {
|
|
65
|
+
dirty = false;
|
|
66
|
+
if (!configured) {
|
|
67
|
+
const next = (await readConfiguration(root)).config;
|
|
68
|
+
if (['base', 'outDir', 'theme'].some(key => JSON.stringify(next[key]) !== JSON.stringify(config[key]))) throw new Error('The server address, output or theme-file configuration changed. Restart looma-docs dev to use it.');
|
|
69
|
+
config = next;
|
|
70
|
+
}
|
|
71
|
+
await buildDocumentation({ root, config: await themeConfiguration(root, config, true), catalog });
|
|
72
|
+
}
|
|
73
|
+
})().finally(() => { building = undefined; });
|
|
74
|
+
return building;
|
|
75
|
+
};
|
|
76
|
+
await rebuild();
|
|
77
|
+
const upstream = await previewProjectDocumentation({ root, config, port: 0 });
|
|
78
|
+
let gateway;
|
|
79
|
+
try {
|
|
80
|
+
gateway = await serveLocalDocumentation({ upstream: upstream.url, store, base: config.base ?? '/', port, onSaved: async () => { dirty = true; await rebuild(); } });
|
|
81
|
+
} catch (error) { await upstream.close(); throw error; }
|
|
82
|
+
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
83
|
+
if (!filename || closed) return;
|
|
84
|
+
const relative = String(filename).split(path.sep).join('/');
|
|
85
|
+
if (relative.split('/').some(part => part === 'node_modules' || part === '.git' || part === '.looma')) return;
|
|
86
|
+
const output = config.outDir ?? '.looma/site';
|
|
87
|
+
if (relative === output || relative.startsWith(output + '/')) return;
|
|
88
|
+
const inputs = [configName, config.theme?.recipe ?? 'looma.theme.json', config.theme?.css ?? 'looma.theme.css', config.content, ...(config.components ?? []).map(entry => entry.directory), ...(config.overrides ?? [])].filter(Boolean).map(value => value.replace(/^\.\//, ''));
|
|
89
|
+
if (!inputs.some(input => relative === input || relative.startsWith(input + '/'))) return;
|
|
90
|
+
dirty = true;
|
|
91
|
+
clearTimeout(timer); timer = setTimeout(() => { void rebuild().catch(onError); }, 150);
|
|
92
|
+
});
|
|
93
|
+
return { url: gateway.url, close: async () => { closed = true; clearTimeout(timer); watcher.close(); await building?.catch(() => {}); await gateway.close(); await upstream.close(); } };
|
|
94
|
+
}
|