@threadlabs/looma-docs 0.0.0-stage → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer, request as proxyRequest } from 'node:http';
|
|
3
|
+
|
|
4
|
+
const rejected = (message, status = 403) => Object.assign(new Error(message), { status });
|
|
5
|
+
const secretMatches = (value, secret) => typeof value === 'string' && Buffer.byteLength(value) === secret.length && timingSafeEqual(Buffer.from(value), Buffer.from(secret));
|
|
6
|
+
async function body(request) {
|
|
7
|
+
const chunks = []; let size = 0;
|
|
8
|
+
for await (const chunk of request) {
|
|
9
|
+
size += chunk.length;
|
|
10
|
+
if (size > 64 * 1024) throw rejected('Theme request is too large.', 413);
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
}
|
|
13
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
14
|
+
catch { throw rejected('Expected a JSON theme recipe and revision.', 400); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Serve trusted project documentation with a constrained, same-origin theme writer. */
|
|
18
|
+
export async function serveLocalDocumentation({ upstream, store, base = '/', port = 0, onSaved = async () => {} }) {
|
|
19
|
+
const target = new URL(upstream);
|
|
20
|
+
if (target.protocol !== 'http:' || target.hostname !== '127.0.0.1') throw new Error('The documentation upstream must be a loopback MarkupKit server.');
|
|
21
|
+
const capability = randomBytes(32).toString('hex');
|
|
22
|
+
let origin;
|
|
23
|
+
const json = (response, status, data) => {
|
|
24
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
25
|
+
response.end(JSON.stringify(data));
|
|
26
|
+
};
|
|
27
|
+
const sameOrigin = request => {
|
|
28
|
+
if (request.headers.origin) return request.headers.origin === origin;
|
|
29
|
+
return request.headers['sec-fetch-site'] === 'same-origin';
|
|
30
|
+
};
|
|
31
|
+
const server = createServer((request, response) => {
|
|
32
|
+
void (async () => {
|
|
33
|
+
if (request.headers.host !== new URL(origin).host) throw rejected('Unexpected local documentation host.');
|
|
34
|
+
const url = new URL(request.url, origin);
|
|
35
|
+
const api = base + '_looma/';
|
|
36
|
+
if (url.pathname === api + 'session' || url.pathname === api + 'theme') {
|
|
37
|
+
if (!sameOrigin(request)) throw rejected('Open this editor from its local documentation address.');
|
|
38
|
+
if (url.pathname.endsWith('/session')) {
|
|
39
|
+
if (request.method !== 'GET') throw rejected('Use GET for the local session.', 405);
|
|
40
|
+
json(response, 200, { ...await store.read(), capability }); return;
|
|
41
|
+
}
|
|
42
|
+
if (request.method !== 'POST') throw rejected('Use POST to save a theme.', 405);
|
|
43
|
+
if (request.headers['content-type']?.split(';')[0].trim() !== 'application/json') throw rejected('Use application/json to save a theme.', 415);
|
|
44
|
+
if (!secretMatches(request.headers['x-looma-capability'], capability)) throw rejected('The local editing session expired. Reload before saving.');
|
|
45
|
+
const result = await store.save(await body(request));
|
|
46
|
+
try { await onSaved(result); }
|
|
47
|
+
catch (error) { throw rejected(`Theme files were saved, but the documentation could not rebuild: ${error.message}`, 500); }
|
|
48
|
+
json(response, 200, result); return;
|
|
49
|
+
}
|
|
50
|
+
if (!['GET', 'HEAD'].includes(request.method)) throw rejected('Documentation supports GET and HEAD.', 405);
|
|
51
|
+
const headers = { ...request.headers, host: target.host };
|
|
52
|
+
delete headers['x-looma-capability'];
|
|
53
|
+
const proxy = proxyRequest(target.origin + request.url, { method: request.method, headers }, remote => {
|
|
54
|
+
const responseHeaders = { ...remote.headers };
|
|
55
|
+
|
|
56
|
+
response.writeHead(remote.statusCode, responseHeaders); remote.pipe(response);
|
|
57
|
+
});
|
|
58
|
+
proxy.on('error', () => { if (!response.headersSent) json(response, 502, { error: 'The documentation renderer is unavailable.' }); else response.destroy(); });
|
|
59
|
+
request.on('aborted', () => proxy.destroy()); proxy.end();
|
|
60
|
+
})().catch(error => { if (!response.headersSent) json(response, error.status ?? 500, { error: error.status ? error.message : 'Could not process this local documentation request.' }); else response.destroy(); });
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
63
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
64
|
+
return { url: origin + base, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Order the main links and group onboarding guidance in the server-rendered sidebar. */
|
|
2
|
+
export function orderNavigation(options, { nav = [], base }) {
|
|
3
|
+
const order = new Map(nav.filter(entry => entry.position === 'start').map((entry, index) => [entry.href, index]));
|
|
4
|
+
const localPath = href => href?.startsWith(base) ? '/' + href.slice(base.length).replace(/^v\/[^/]+\//, '') : href;
|
|
5
|
+
const rank = entry => order.get(localPath(entry.href)) ?? order.size;
|
|
6
|
+
const layout = layer => {
|
|
7
|
+
if (!layer?.server?.load) return layer;
|
|
8
|
+
const load = layer.server.load;
|
|
9
|
+
return { ...layer, server: { ...layer.server, async load(context) {
|
|
10
|
+
const result = await load(context);
|
|
11
|
+
const shell = result.data?.markupress;
|
|
12
|
+
if (!shell?.nav) return result;
|
|
13
|
+
const links = [...shell.nav].sort((left, right) => rank(left) - rank(right));
|
|
14
|
+
const guideRank = node => ({ '/': -1, '/getting-started/': 0, '/design-system/': 1 })[localPath(node.href)] ?? 2;
|
|
15
|
+
const mainSections = new Set(shell.nav.map(link => localPath(link.href)));
|
|
16
|
+
const guidance = shell.sidebar?.some(node => localPath(node.href) === '/design-system/');
|
|
17
|
+
let sidebar = shell.sidebar;
|
|
18
|
+
if (guidance && context.navigationTree && shell.home) {
|
|
19
|
+
sidebar = await context.navigationTree({ from: '/' + shell.home.slice(base.length), current: context.url.pathname });
|
|
20
|
+
sidebar = sidebar.map(node => {
|
|
21
|
+
const overview = node.children.find(child => localPath(child.href) === '/design-system/');
|
|
22
|
+
return overview ? { ...node, href: overview.href, current: overview.current, overviewLabel: 'Overview',
|
|
23
|
+
children: node.children.filter(child => child !== overview) } : node;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
sidebar = guidance ? sidebar
|
|
27
|
+
.filter(node => !(['/components/', '/customize/', '/editor/'].includes(localPath(node.href)) && mainSections.has(localPath(node.href))))
|
|
28
|
+
.sort((left, right) => guideRank(left) - guideRank(right)) : sidebar;
|
|
29
|
+
const chrome = { nav: links, ...(sidebar ? { sidebar } : {}), ...(guidance && context.navigationTree ? { sidebarNavigation: 'tree' } : {}) };
|
|
30
|
+
return { ...result,
|
|
31
|
+
props: { ...result.props, ...Object.fromEntries(Object.entries(chrome).filter(([name]) => name in (result.props ?? {}))) },
|
|
32
|
+
data: { ...result.data, markupress: { ...shell, ...chrome } },
|
|
33
|
+
};
|
|
34
|
+
} } };
|
|
35
|
+
};
|
|
36
|
+
return { ...options,
|
|
37
|
+
...(options.pages ? { pages: options.pages.map(page => ({ ...page, layout: layout(page.layout) })) } : {}),
|
|
38
|
+
...(options.layouts ? { layouts: Object.fromEntries(Object.entries(options.layouts).map(([name, layer]) => [name, layout(layer)])) } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
package/docs/palette.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Perceptual palette operations use Oklab/OKLCH; output remains portable sRGB hex.
|
|
2
|
+
const linear = value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
|
|
3
|
+
const encoded = value => value <= .0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - .055;
|
|
4
|
+
const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));
|
|
5
|
+
export function toLch(hex) {
|
|
6
|
+
const [r, g, b] = [1, 3, 5].map(i => linear(parseInt(hex.slice(i, i + 2), 16) / 255));
|
|
7
|
+
const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
|
|
8
|
+
const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
|
|
9
|
+
const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
|
|
10
|
+
const a = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;
|
|
11
|
+
const bb = .0259040371 * l + .7827717662 * m - .808675766 * s;
|
|
12
|
+
return [.2104542553 * l + .793617785 * m - .0040720468 * s, Math.hypot(a, bb), (Math.atan2(bb, a) * 180 / Math.PI + 360) % 360];
|
|
13
|
+
}
|
|
14
|
+
const channels = ([lightness, chroma, hue]) => {
|
|
15
|
+
const a = chroma * Math.cos(hue * Math.PI / 180), b = chroma * Math.sin(hue * Math.PI / 180);
|
|
16
|
+
const l = (lightness + .3963377774 * a + .2158037573 * b) ** 3;
|
|
17
|
+
const m = (lightness - .1055613458 * a - .0638541728 * b) ** 3;
|
|
18
|
+
const s = (lightness - .0894841775 * a - 1.291485548 * b) ** 3;
|
|
19
|
+
return [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s];
|
|
20
|
+
};
|
|
21
|
+
export function fromLch([lightness, chroma, hue]) {
|
|
22
|
+
lightness = clamp(lightness); chroma = Math.max(0, chroma);
|
|
23
|
+
let rgb = channels([lightness, chroma, hue]);
|
|
24
|
+
if (rgb.some(n => n < -.000001 || n > 1.000001)) {
|
|
25
|
+
// Reduce chroma at fixed hue/lightness instead of clipping individual channels.
|
|
26
|
+
let low = 0, high = chroma;
|
|
27
|
+
for (let i = 0; i < 18; i++) {
|
|
28
|
+
const middle = (low + high) / 2;
|
|
29
|
+
if (channels([lightness, middle, hue]).every(n => n >= 0 && n <= 1)) low = middle;
|
|
30
|
+
else high = middle;
|
|
31
|
+
}
|
|
32
|
+
rgb = channels([lightness, low, hue]);
|
|
33
|
+
}
|
|
34
|
+
return '#' + rgb.map(n => Math.round(clamp(encoded(n)) * 255).toString(16).padStart(2, '0')).join('');
|
|
35
|
+
}
|
|
36
|
+
export function mixColour(a, b, weight) {
|
|
37
|
+
// Cartesian Oklab mixing avoids a hue discontinuity around 0/360 degrees.
|
|
38
|
+
const cartesian = hex => { const [l, c, h] = toLch(hex); return [l, c * Math.cos(h * Math.PI / 180), c * Math.sin(h * Math.PI / 180)]; };
|
|
39
|
+
const first = cartesian(a), second = cartesian(b), mixed = first.map((n, i) => n * weight + second[i] * (1 - weight));
|
|
40
|
+
return fromLch([mixed[0], Math.hypot(mixed[1], mixed[2]), Math.atan2(mixed[2], mixed[1]) * 180 / Math.PI]);
|
|
41
|
+
}
|
|
42
|
+
export function paletteSeeds(values) {
|
|
43
|
+
const intensity = Number(values['color-intensity']) * 2, warmth = (Number(values.warmth) - .5) * 2;
|
|
44
|
+
const relationship = values['palette-relationship'];
|
|
45
|
+
const brandHue = toLch(values.action)[2];
|
|
46
|
+
return Object.fromEntries(['action', 'secondary-action', 'tertiary-action', 'canvas', 'surface', 'text'].map(key => {
|
|
47
|
+
const seed = values[key] === 'none' ? values.action : values[key];
|
|
48
|
+
if (intensity === 1 && warmth === 0 && relationship === 'preserve') return [key, seed];
|
|
49
|
+
let [l, c, h] = toLch(seed);
|
|
50
|
+
if (!['action','secondary-action','tertiary-action'].includes(key)) {
|
|
51
|
+
const tint = Math.max(Math.abs(warmth), relationship === 'preserve' ? 0 : .5);
|
|
52
|
+
if (tint) l = clamp(l, .08 * tint, 1 - .03 * tint);
|
|
53
|
+
if (relationship !== 'preserve') { h = brandHue + (relationship === 'neighboring' ? 30 : 180); c = Math.max(c, .018); }
|
|
54
|
+
if (warmth !== 0) {
|
|
55
|
+
const strength = Math.abs(warmth);
|
|
56
|
+
const target = fromLch([l, warmth > 0 ? .025 : .018, warmth > 0 ? 85 : 255]);
|
|
57
|
+
[l, c, h] = toLch(mixColour(fromLch([l, c, h]), target, 1 - strength));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [key, fromLch([l, c * intensity, h])];
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function companionSurface(seed, mode) {
|
|
65
|
+
const [l, c, h] = toLch(seed);
|
|
66
|
+
// Retain hue and restrained colorfulness; convert the tonal role, not the identity.
|
|
67
|
+
return fromLch([mode === 'dark' ? .16 + (1 - l) * .12 : .94 + (1 - l) * .045, Math.min(c, .045), h]);
|
|
68
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400" role="img" aria-labelledby="title description">
|
|
2
|
+
<title id="title">A quiet afternoon in the countryside</title>
|
|
3
|
+
<desc id="description">Soft green hills surround a red-roofed farmhouse, a winding stream, and sheep grazing beneath a broad oak tree.</desc>
|
|
4
|
+
<rect width="640" height="400" fill="#e9efdf"/>
|
|
5
|
+
<circle cx="486" cy="85" r="36" fill="#edcd79"/>
|
|
6
|
+
<g fill="#faf8ed">
|
|
7
|
+
<path d="M65 89c0-12 10-21 22-20 4-20 35-24 46-6 16-4 30 8 29 23 14-2 25 7 25 18H65Z"/>
|
|
8
|
+
<path d="M300 58c2-11 17-16 26-9 10-15 33-10 36 7 13-3 25 6 25 17h-87Z"/>
|
|
9
|
+
</g>
|
|
10
|
+
<path d="M0 183c76-55 128-46 196-8 67 37 112-68 199-45 85 22 134 67 245 22v248H0Z" fill="#a9c2ad"/>
|
|
11
|
+
<path d="M0 227c108-62 189-36 285-8 111 33 182-60 355-18v199H0Z" fill="#89aa79"/>
|
|
12
|
+
<path d="M0 287c89-20 165-64 267-45 110 21 219 79 373 18v140H0Z" fill="#6b925e"/>
|
|
13
|
+
<path d="M0 349c128-72 240-22 363-4 116 17 190-36 277-31v86H0Z" fill="#52784f"/>
|
|
14
|
+
<path d="M362 211c-43 31 63 45 28 72-35 26-113 35-99 64 9 20 54 32 84 53h92c-40-44-127-52-124-72 3-19 79-31 84-57 6-30-94-38-55-60Z" fill="#c7deda"/>
|
|
15
|
+
<path d="M368 213c-28 26 57 44 31 65" fill="none" stroke="#e5eee0" stroke-width="4" stroke-linecap="round"/>
|
|
16
|
+
<path d="M168 214c-21 37-74 45-90 75-17 33 26 56 21 111h40c-1-57-48-77-38-105 10-27 72-45 80-81Z" fill="#d9c38b"/>
|
|
17
|
+
<g>
|
|
18
|
+
<rect x="121" y="173" width="88" height="53" fill="#f6ead0"/>
|
|
19
|
+
<path d="m109 177 56-39 56 39Z" fill="#a55d48"/>
|
|
20
|
+
<path d="m118 177 47-32 47 32Z" fill="#bd7458"/>
|
|
21
|
+
<rect x="181" y="191" width="15" height="35" rx="1" fill="#637961"/>
|
|
22
|
+
<rect x="135" y="189" width="20" height="19" rx="1" fill="#75999a"/>
|
|
23
|
+
<path d="M145 189v19m-10-9h20" stroke="#f6ead0" stroke-width="3"/>
|
|
24
|
+
<path d="M194 155v-17h9v23" fill="#a55d48"/>
|
|
25
|
+
<rect x="238" y="192" width="47" height="35" fill="#b67456"/>
|
|
26
|
+
<path d="m231 194 30-26 31 26Z" fill="#805947"/>
|
|
27
|
+
<rect x="253" y="207" width="16" height="20" fill="#765940"/>
|
|
28
|
+
</g>
|
|
29
|
+
<g fill="none" stroke="#f0dfb7" stroke-width="4" stroke-linecap="round">
|
|
30
|
+
<path d="m27 253 76-18m-65 3v27m25-34v27m25-34v27"/>
|
|
31
|
+
<path d="m222 248 74 11m-63-24v27m26-22v27m26-23v27"/>
|
|
32
|
+
</g>
|
|
33
|
+
<g>
|
|
34
|
+
<path d="m535 220-8 112h24l-6-112Z" fill="#6d6246"/>
|
|
35
|
+
<path d="m539 263-23-28m24 14 23-24" fill="none" stroke="#6d6246" stroke-width="8" stroke-linecap="round"/>
|
|
36
|
+
<path d="M482 232c-26-5-33-36-14-54-10-23 9-45 33-42 9-28 48-31 64-8 26-13 55 6 52 32 28 10 29 45 6 59 0 23-35 34-51 19-24 21-63 16-67-5Z" fill="#3c684d"/>
|
|
37
|
+
<path d="M484 174c4-21 29-30 44-18 9-24 45-22 53 3 22-5 38 13 34 31-43-17-86 6-131-16Z" fill="#527d55"/>
|
|
38
|
+
</g>
|
|
39
|
+
<g stroke="#465b48" stroke-width="3" stroke-linecap="round">
|
|
40
|
+
<path d="M385 323v9m17-10v9m47 13v8m16-10v9"/>
|
|
41
|
+
</g>
|
|
42
|
+
<g fill="#f4edda">
|
|
43
|
+
<ellipse cx="392" cy="317" rx="16" ry="10"/>
|
|
44
|
+
<ellipse cx="455" cy="338" rx="14" ry="9"/>
|
|
45
|
+
</g>
|
|
46
|
+
<g fill="#465b48">
|
|
47
|
+
<ellipse cx="406" cy="318" rx="5" ry="7"/>
|
|
48
|
+
<ellipse cx="468" cy="338" rx="4" ry="6"/>
|
|
49
|
+
</g>
|
|
50
|
+
<g fill="#e4ca80">
|
|
51
|
+
<circle cx="41" cy="370" r="3"/><circle cx="56" cy="351" r="3"/><circle cx="186" cy="346" r="3"/>
|
|
52
|
+
<circle cx="202" cy="364" r="3"/><circle cx="569" cy="363" r="3"/><circle cx="590" cy="346" r="3"/>
|
|
53
|
+
</g>
|
|
54
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-labelledby="title description">
|
|
2
|
+
<title id="title">Illustrated portrait of Maya Chen</title>
|
|
3
|
+
<desc id="description">A warm, abstract profile portrait on a violet background.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#8078ed"/>
|
|
7
|
+
<stop offset="1" stop-color="#40389e"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<rect width="96" height="96" rx="24" fill="url(#background)"/>
|
|
11
|
+
<circle cx="48" cy="38" r="18" fill="#f5c9a7"/>
|
|
12
|
+
<path d="M29 38c0-17 10-25 21-25 13 0 22 10 21 26-5-8-13-13-24-13-7 0-13 4-18 12Z" fill="#292238"/>
|
|
13
|
+
<path d="M18 96c2-23 13-35 30-35s28 12 30 35H18Z" fill="#f6f3ff"/>
|
|
14
|
+
<path d="M39 43c3 3 15 3 18 0" fill="none" stroke="#a45b50" stroke-width="2" stroke-linecap="round"/>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<template component="looma-reference" module="./reference.js"><defs></defs><div><slot></slot></div></template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A deep link opens its containing tab through the tab's native, accessible activation. */
|
|
2
|
+
export function connect(host) {
|
|
3
|
+
const root = host.element;
|
|
4
|
+
const view = root.ownerDocument.defaultView;
|
|
5
|
+
let currentHash = view.location.hash;
|
|
6
|
+
const open = () => {
|
|
7
|
+
let id;
|
|
8
|
+
try { id = decodeURIComponent(currentHash.slice(1)); } catch { return; }
|
|
9
|
+
const target = root.ownerDocument.getElementById(id);
|
|
10
|
+
if (!target || !root.contains(target)) { observer.disconnect(); return; }
|
|
11
|
+
const panel = target.closest('[role="tabpanel"]');
|
|
12
|
+
const tab = panel && [...root.querySelectorAll('[role="tab"]')].find(button => button.getAttribute('aria-controls') === panel.id);
|
|
13
|
+
if (!tab) return;
|
|
14
|
+
if (panel.hidden) tab.click();
|
|
15
|
+
if (!panel.hidden) { target.scrollIntoView({ block: 'start' }); observer.disconnect(); }
|
|
16
|
+
};
|
|
17
|
+
const observer = new MutationObserver(open);
|
|
18
|
+
const activate = value => {
|
|
19
|
+
currentHash = value;
|
|
20
|
+
observer.disconnect();
|
|
21
|
+
if (!currentHash) return;
|
|
22
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['role', 'id', 'hidden'] });
|
|
23
|
+
open();
|
|
24
|
+
};
|
|
25
|
+
const hash = () => activate(view.location.hash);
|
|
26
|
+
const click = event => {
|
|
27
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
28
|
+
const link = event.target.closest?.('a[href]');
|
|
29
|
+
if (!link || link.target && link.target !== '_self') return;
|
|
30
|
+
const target = new URL(link.href, view.location.href);
|
|
31
|
+
if (target.origin === view.location.origin && target.pathname === view.location.pathname && target.hash) activate(target.hash);
|
|
32
|
+
};
|
|
33
|
+
root.addEventListener('click', click);
|
|
34
|
+
view.addEventListener('hashchange', hash); hash();
|
|
35
|
+
return () => { observer.disconnect(); root.removeEventListener('click', click); view.removeEventListener('hashchange', hash); };
|
|
36
|
+
}
|
package/docs/render.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { escapeHtml as escape } from './catalog.mjs';
|
|
3
|
+
import { parse } from 'expressmark';
|
|
4
|
+
import { renderHtml } from 'expressmark/render';
|
|
5
|
+
import { parseFragment } from 'parse5';
|
|
6
|
+
import { frameworkExamples, frameworkLabels } from './framework-examples.mjs';
|
|
7
|
+
|
|
8
|
+
const descriptionDocument = description => parse(description ?? '', { syntax: 'standard' });
|
|
9
|
+
// This HTML enters Markdown again. Entities protect rendered text from a second
|
|
10
|
+
// interpretation; existing entities stay intact. Markupress owns final HTML Next escaping.
|
|
11
|
+
const descriptionHtml = description => renderHtml(descriptionDocument(description), { escapeText: html => html.replace(/[!-/:-@[-`{-~]/g, character => character === '&' || character === ';' ? character : `&#${character.codePointAt(0)};`) }).replaceAll('\n', ' ');
|
|
12
|
+
function descriptionText(node) {
|
|
13
|
+
if (node.nodeName === '#text') return node.value;
|
|
14
|
+
if (node.tagName === 'img') return node.attrs.find(attribute => attribute.name === 'alt')?.value ?? '';
|
|
15
|
+
if (node.tagName === 'br') return '\n';
|
|
16
|
+
return (node.childNodes ?? []).map(descriptionText).join(node.nodeName === '#document-fragment' ? ' ' : '');
|
|
17
|
+
}
|
|
18
|
+
export const cardDescription = description => descriptionText(parseFragment(renderHtml(descriptionDocument(description)))).split(/(?<=[.!?])\s/)[0];
|
|
19
|
+
|
|
20
|
+
const literal = value => escape(value).replaceAll('{', '\\{');
|
|
21
|
+
const code = value => `<code>${escape(value ?? '—')}</code>`;
|
|
22
|
+
const table = (head, rows) => `<table><thead><tr>${head.map(h => `<th>${literal(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${value}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
23
|
+
export const exampleKey = id => createHash('sha256').update(id).digest('hex').slice(0, 20);
|
|
24
|
+
export const componentPath = component => `/${component.group === 'editor' ? 'editor' : 'components'}/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
|
|
25
|
+
export const previewTag = example => `looma-preview-${exampleKey(example.id)}`;
|
|
26
|
+
export const previewMarkup = (example, namespace, base = '/', layout = false) => `<div slot="preview"><div data-markupress-example data-preview="${escape(example.id)}"${layout || example.stretch ? ' data-preview-layout' : ''}>${scopedExampleMarkup(example, base, namespace)}</div></div>`;
|
|
27
|
+
export function exampleResources(examples, catalog) {
|
|
28
|
+
const resources = new Map([...catalog.resources ?? [], ...catalog.components].map(resource => [resource.tag, resource.resource]));
|
|
29
|
+
const tags = [...new Set(examples.flatMap(example => [...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1])))];
|
|
30
|
+
return tags.filter(tag => resources.has(tag)).map(tag => `<link rel="component" href="${escape(resources.get(tag))}">`).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Keep authored ID relationships local when several examples share one document. */
|
|
34
|
+
export function scopedExampleMarkup(example, base = '/', namespace) {
|
|
35
|
+
const ids = new Set([...example.markup.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]));
|
|
36
|
+
const destinations = [...new Set([...example.markup.matchAll(/\bhref=["']#([^"']+)["']/g)].map(match => match[1]))].filter(id => !ids.has(id));
|
|
37
|
+
const markup = example.markup.replaceAll('src="/_looma/', `src="${base}_looma/`) + destinations.map(id => `<span id="${escape(id)}" hidden></span>`).join('');
|
|
38
|
+
return markup.replace(/<([a-z][\w:-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi, (tag, name, attributes) => {
|
|
39
|
+
if (name === 'img') attributes = attributes.replace(/\ssrc="([^"]*\/_looma\/[^"]*)"/, (_, url) => ` from:src="${escape(JSON.stringify(url))}"`);
|
|
40
|
+
const scoped = attributes.replace(/\s(id|for|commandfor|popovertarget|aria-labelledby|aria-describedby|aria-controls|aria-owns|aria-activedescendant|list|form|headers|href|name)\s*=\s*(["'])(.*?)\2/gi, (attribute, key, quote, value) => {
|
|
41
|
+
if (key === 'name' && !['ui-radio', 'ui-radio-group'].includes(name) && !(name === 'input' && /\btype=["']radio["']/.test(attributes))) return attribute;
|
|
42
|
+
if (!value || key === 'href' && !value.startsWith('#')) return attribute;
|
|
43
|
+
if (namespace) {
|
|
44
|
+
const scopedValue = (key === 'href' ? '#' : '') + value.replace(/^#/, '').split(/\s+/).map(id => `${namespace}-${id}`).join(' ');
|
|
45
|
+
return `${key === 'id' && !/\bdata-example-id=/.test(attributes) ? ` data-example-id="${escape(value)}"` : ''} ${key}="${escape(scopedValue)}"`;
|
|
46
|
+
}
|
|
47
|
+
const values = value.replace(/^#/, '').split(/\s+/);
|
|
48
|
+
const binding = values.map(id => `concat($namespace, '-', ${JSON.stringify(id)})`).join(', " ", ');
|
|
49
|
+
const source = key === 'href' ? `concat('#', ${binding})` : values.length > 1 ? `concat(${binding})` : `concat($namespace, '-', ${JSON.stringify(value)})`;
|
|
50
|
+
return `${key === 'id' && !/\bdata-example-id=/.test(attributes) ? ` data-example-id="${escape(value)}"` : ''} from:${key}="${escape(source)}"`;
|
|
51
|
+
});
|
|
52
|
+
return `<${name}${scoped}>`;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function apiBody(api, prefix = '') {
|
|
57
|
+
const attributes = api.attributes ?? [];
|
|
58
|
+
const properties = api.properties ?? [];
|
|
59
|
+
const events = api.events ?? [];
|
|
60
|
+
const slots = api.slots ?? [];
|
|
61
|
+
const tokens = api.designTokens ?? { component: [], shared: [] };
|
|
62
|
+
const section = (name, items, headings, render) => `<h2 id="${prefix}${name.toLowerCase().replaceAll(' ', '-')}">${name}</h2>${items.length ? table(headings, items.map(render)) : `<p>No ${name.toLowerCase()}.</p>`}`;
|
|
63
|
+
const fields = value => value?.length ? `<ul>${value.map(field => `<li>${code(field.path)}: ${code(field.type)}${field.required ? ' (required)' : ''}</li>`).join('')}</ul>` : '';
|
|
64
|
+
return `<p><strong>Package:</strong> ${code(api.package)} · <strong>Native root:</strong> ${code(api.root)}</p>`
|
|
65
|
+
+ section('Attributes', attributes, ['Name', 'Description', 'Property', 'Type', 'Default', 'Allowed values'], p => [code(p.name), literal(p.description ?? ''), code(p.property), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))])
|
|
66
|
+
+ (api.targets?.includes('vue') ? section('Framework props', properties, ['Name', 'Type', 'Default', 'Allowed values'], p => [code(p.name), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))]) : '')
|
|
67
|
+
+ section('Events', events, ['Name', 'Detail', 'Description'], e => [code(e.name), code(e.detailSchema ?? e.detailType) + fields(e.fields), literal(e.detailDocs ?? '')])
|
|
68
|
+
+ section('Slots', slots, ['Name', 'Purpose'], s => [code(s.name), literal(s.description ?? '')])
|
|
69
|
+
+ `<h2 id="${prefix}design-tokens">Design tokens</h2><p>Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.</p>`
|
|
70
|
+
+ section('Component tokens', tokens.component ?? [], ['Token', 'Declared values', 'Fallback'], t => [code(t.name), code(t.declarations?.join(' | ')), code(t.fallbacks?.join(' | '))])
|
|
71
|
+
+ section('Shared tokens', tokens.shared ?? [], ['Token', 'Fallback'], t => [code(t.name), code(t.fallbacks?.join(' | '))]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const exampleCodes = (example, component, components) => frameworkExamples(example, components);
|
|
75
|
+
|
|
76
|
+
/** The same card invocation is embedded in reference pages and rendered by the customizer. */
|
|
77
|
+
export function cardMarkup(example, base = '/', component = {}, { reference = false, code: exampleCode = '' } = {}) {
|
|
78
|
+
return `<looma-example-card identity="${escape(example.id)}"${reference ? ' reference' : ''} title="${escape(component.title ?? example.title)}" description="${escape(reference ? example.description : cardDescription(component.summary ?? component.description ?? example.description))}" example-title="${escape(example.title)}" category="${escape(component.group ?? 'forms')}" tag="${escape(component.tag ?? '')}" package-name="${escape(component.package ?? '')}" native-root="${escape(component.root ? `<${component.root}>` : '')}" href="${component.tag ? base + componentPath(component).slice(1) : ''}"${example.resizable ? ' resizable' : ''}>${previewMarkup(example, `${reference ? 'reference' : 'catalog'}-${exampleKey(example.id)}`, base, component.group === 'layout')}${reference ? `<div slot="heading" class="looma-example-heading"><h3 id="example-${exampleKey(example.id)}">${escape(example.title)}</h3><p>${escape(example.description)}</p></div>` : ''}${exampleCode}</looma-example-card>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function referencePage(component, catalog, cardResource, base) {
|
|
82
|
+
const parts = catalog.components.filter(c => c.navigationParent === component.tag);
|
|
83
|
+
const examples = [...component.examples, ...parts.flatMap(part => part.examples)];
|
|
84
|
+
const codes = example => {
|
|
85
|
+
const owner = catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component;
|
|
86
|
+
// Blank lines let Markupress parse real fences inside the projected HTML slots.
|
|
87
|
+
// A fence longer than any authored run keeps snippets literal, including nested fences.
|
|
88
|
+
const tabs = Object.entries(exampleCodes(example, owner, [...catalog.resources ?? [], ...catalog.components])).map(([mode, value]) => {
|
|
89
|
+
const fence = '~'.repeat(Math.max(3, ...[...value.code.matchAll(/~+/g)].map(match => match[0].length + 1)));
|
|
90
|
+
const language = /^[\w+-]+$/.test(value.language) ? value.language : 'text';
|
|
91
|
+
return `<section aria-label="${frameworkLabels[mode]}"><div data-theme="dark" style="--ui-surface-sunken: #0d1117">\n\n${fence}${language}\n${value.code}\n${fence}\n\n</div></section>`;
|
|
92
|
+
}).join('\n');
|
|
93
|
+
return `<ui-tabs slot="code" label="Example syntax" group="syntax" style="--ui-tabs-panel-padding: 0px">${tabs}</ui-tabs>`;
|
|
94
|
+
};
|
|
95
|
+
const body = `<div class="looma-reference-examples"><ui-stack gap="xl">${examples.map(example => cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component, { reference: true, code: codes(example) })).join('')}</ui-stack></div>`;
|
|
96
|
+
return `---\ntitle: ${JSON.stringify(component.title)}\ndescription: ${JSON.stringify(component.description || `${component.title} component reference.`)}\nid: ${JSON.stringify(component.id)}\n---\n\n# ${escape(component.title)}\n\n<div class="component-description">${descriptionHtml(component.description)}</div>\n\n<link rel="component" href="${cardResource}">\n${exampleResources(examples, catalog)}\n<link rel="component" href="${cardResource.replace(/card\.html$/, 'reference.html')}">\n<link rel="component" href="@threadlabs/looma/components/ui-card/ui-card.html">\n<link rel="component" href="@threadlabs/looma/components/ui-tabs/ui-tabs.html">\n<link rel="component" href="@threadlabs/looma/components/ui-disclosure/ui-disclosure.html">\n<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">\n\n<looma-reference>\n\n<div class="looma-reference-tabs"><ui-tabs label="Component reference" style="--ui-tabs-panel-padding: 0px; --ui-tabs-tab-min-block-size: var(--ui-control-size-lg)"><section aria-label="Examples">${body}</section><section aria-label="API"><h2 id="api">API</h2>\n${apiBody(component)}\n${parts.map(part => `<h2 id="${part.tag}-api">${literal(part.title)} API</h2><p>${literal(part.description)}</p>${apiBody(part, part.tag + '-')}`).join('\n')}</section></ui-tabs></div>\n\n</looma-reference>\n`;
|
|
97
|
+
}
|