create-obsidian-arrow 0.2.1 → 0.3.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/README.md +5 -4
- package/package.json +1 -1
- package/template/AGENTS.md +27 -3
- package/template/README.md +40 -1
- package/template/_gitignore +3 -0
- package/template/docs/prompts/agent-setup.md +22 -11
- package/template/docs/prompts/update-existing.md +1 -1
- package/template/docs/workflow.md +18 -6
- package/template/package.json +1 -1
- package/template/src/components/SettingsPanel.stories.ts +11 -0
- package/template/src/components/SettingsPanel.ts +1 -1
- package/template/src/components/Toggle.stories.ts +28 -0
- package/template/src/main.ts +1 -0
- package/template/src/router/client.ts +15 -2
- package/template/src/router/routeToPage.ts +75 -28
- package/template/src/sandbox/home.ts +135 -0
- package/template/src/sandbox/sandbox.css +307 -0
- package/template/src/utilities.css +205 -0
- package/template/src/viewer/ClassesPage.ts +37 -0
- package/template/src/viewer/ComponentsIndex.ts +56 -0
- package/template/src/viewer/StoryPage.ts +73 -0
- package/template/src/viewer/TokensPage.ts +82 -0
- package/template/src/viewer/derive.ts +81 -0
- package/template/src/viewer/discovery.ts +63 -0
- package/template/src/viewer/obsidian-classes.ts +269 -0
- package/template/src/viewer/sidebar.ts +55 -0
- package/template/src/viewer/stories.ts +83 -0
- package/template/src/viewer/token-utils.ts +84 -0
- package/template/src/viewer/tokens.ts +30 -0
- package/template/test/token-utils.test.mjs +65 -0
- package/template/test/viewer-derive.test.mjs +65 -0
- package/template/test/viewer-stories.test.mjs +44 -0
- package/template/src/examples/ExamplesIndex.ts +0 -36
- package/template/src/examples/registry.ts +0 -26
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure token parsing/grouping for the reference index. The browser side feeds
|
|
3
|
+
* this CSSOM rule text (rule.cssText); keeping the parser string-in/data-out
|
|
4
|
+
* makes it node:test-able without a DOM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TokenDecl {
|
|
8
|
+
name: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseCustomProps(cssText: string): TokenDecl[] {
|
|
13
|
+
const out: TokenDecl[] = [];
|
|
14
|
+
const re = /(--[A-Za-z0-9_-]+)\s*:\s*([^;}]+)/g;
|
|
15
|
+
let match = re.exec(cssText);
|
|
16
|
+
while (match !== null) {
|
|
17
|
+
out.push({ name: match[1], value: match[2].trim() });
|
|
18
|
+
match = re.exec(cssText);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GROUP_PREFIXES: [string, string][] = [
|
|
24
|
+
["--size-", "Size & spacing"],
|
|
25
|
+
["--radius-", "Radius"],
|
|
26
|
+
["--color-", "Colors"],
|
|
27
|
+
["--background-", "Backgrounds"],
|
|
28
|
+
["--text-", "Text"],
|
|
29
|
+
["--font-", "Fonts & type"],
|
|
30
|
+
["--shadow-", "Shadows"],
|
|
31
|
+
["--interactive-", "Interactive"],
|
|
32
|
+
["--icon-", "Icons"],
|
|
33
|
+
];
|
|
34
|
+
const OTHER_LABEL = "Other";
|
|
35
|
+
|
|
36
|
+
export interface TokenGroup {
|
|
37
|
+
label: string;
|
|
38
|
+
tokens: TokenDecl[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function groupTokens(decls: TokenDecl[]): TokenGroup[] {
|
|
42
|
+
const latest = new Map<string, string>();
|
|
43
|
+
for (const decl of decls) {
|
|
44
|
+
latest.set(decl.name, decl.value);
|
|
45
|
+
}
|
|
46
|
+
const buckets = new Map<string, TokenDecl[]>();
|
|
47
|
+
for (const [name, value] of latest) {
|
|
48
|
+
const label = GROUP_PREFIXES.find(([prefix]) => name.startsWith(prefix))?.[1] ?? OTHER_LABEL;
|
|
49
|
+
const bucket = buckets.get(label) ?? [];
|
|
50
|
+
bucket.push({ name, value });
|
|
51
|
+
buckets.set(label, bucket);
|
|
52
|
+
}
|
|
53
|
+
const order = [...GROUP_PREFIXES.map(([, label]) => label), OTHER_LABEL];
|
|
54
|
+
return order
|
|
55
|
+
.filter((label) => buckets.has(label))
|
|
56
|
+
.map((label) => ({
|
|
57
|
+
label,
|
|
58
|
+
tokens: (buckets.get(label) ?? []).sort((a, b) => a.name.localeCompare(b.name)),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ValueKind = "color" | "length" | "other";
|
|
63
|
+
|
|
64
|
+
export function classifyValue(resolved: string): ValueKind {
|
|
65
|
+
const value = resolved.trim();
|
|
66
|
+
if (
|
|
67
|
+
/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ||
|
|
68
|
+
/^(rgb|rgba|hsl|hsla)\(/i.test(value)
|
|
69
|
+
) {
|
|
70
|
+
return "color";
|
|
71
|
+
}
|
|
72
|
+
if (value === "0" || /^-?\d+(\.\d+)?(px|em|rem|%|ch|vw|vh|vmin|vmax|pt)$/.test(value)) {
|
|
73
|
+
return "length";
|
|
74
|
+
}
|
|
75
|
+
return "other";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function filterTokens(decls: TokenDecl[], query: string): TokenDecl[] {
|
|
79
|
+
const q = query.trim().toLowerCase();
|
|
80
|
+
if (q === "") {
|
|
81
|
+
return decls;
|
|
82
|
+
}
|
|
83
|
+
return decls.filter((decl) => decl.name.toLowerCase().includes(q));
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TokenDecl } from "./token-utils";
|
|
2
|
+
import { parseCustomProps } from "./token-utils";
|
|
3
|
+
|
|
4
|
+
/** Walk every same-origin stylesheet (recursing into @media etc.) and parse
|
|
5
|
+
* custom-property declarations out of each style rule's cssText. */
|
|
6
|
+
export function collectTokenDecls(): TokenDecl[] {
|
|
7
|
+
const out: TokenDecl[] = [];
|
|
8
|
+
const walk = (rules: CSSRuleList): void => {
|
|
9
|
+
for (const rule of Array.from(rules)) {
|
|
10
|
+
if (rule instanceof CSSStyleRule) {
|
|
11
|
+
out.push(...parseCustomProps(rule.cssText));
|
|
12
|
+
} else if (rule instanceof CSSGroupingRule) {
|
|
13
|
+
walk(rule.cssRules);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
18
|
+
try {
|
|
19
|
+
walk(sheet.cssRules);
|
|
20
|
+
} catch {
|
|
21
|
+
// cross-origin sheet — skip
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolved value of a token in the CURRENT theme. */
|
|
28
|
+
export function resolveToken(name: string): string {
|
|
29
|
+
return getComputedStyle(document.body).getPropertyValue(name).trim();
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
classifyValue,
|
|
5
|
+
filterTokens,
|
|
6
|
+
groupTokens,
|
|
7
|
+
parseCustomProps,
|
|
8
|
+
} from "../src/viewer/token-utils.ts";
|
|
9
|
+
|
|
10
|
+
test("parseCustomProps extracts custom property declarations from rule text", () => {
|
|
11
|
+
const css = "body.theme-dark { --text-accent: #a288ff; --size-4-2: 8px; color: red; }";
|
|
12
|
+
assert.deepEqual(parseCustomProps(css), [
|
|
13
|
+
{ name: "--text-accent", value: "#a288ff" },
|
|
14
|
+
{ name: "--size-4-2", value: "8px" },
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseCustomProps does not false-match var() references in values", () => {
|
|
19
|
+
const css = ".x { --a: var(--b); background: var(--c); }";
|
|
20
|
+
assert.deepEqual(parseCustomProps(css), [{ name: "--a", value: "var(--b)" }]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("groupTokens groups by prefix in stable order, dedupes last-wins, sorts names", () => {
|
|
24
|
+
const groups = groupTokens([
|
|
25
|
+
{ name: "--zeta-thing", value: "1" },
|
|
26
|
+
{ name: "--size-4-4", value: "16px" },
|
|
27
|
+
{ name: "--size-4-2", value: "8px" },
|
|
28
|
+
{ name: "--size-4-2", value: "9px" },
|
|
29
|
+
{ name: "--color-red", value: "#e11" },
|
|
30
|
+
]);
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
groups.map((g) => g.label),
|
|
33
|
+
["Size & spacing", "Colors", "Other"]
|
|
34
|
+
);
|
|
35
|
+
const size = groups[0];
|
|
36
|
+
assert.deepEqual(size.tokens, [
|
|
37
|
+
{ name: "--size-4-2", value: "9px" },
|
|
38
|
+
{ name: "--size-4-4", value: "16px" },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("classifyValue detects colors, lengths, other", () => {
|
|
43
|
+
assert.equal(classifyValue("#fff"), "color");
|
|
44
|
+
assert.equal(classifyValue("#a288ffcc"), "color");
|
|
45
|
+
assert.equal(classifyValue("rgba(0, 0, 0, 0.3)"), "color");
|
|
46
|
+
assert.equal(classifyValue("hsl(254, 80%, 68%)"), "color");
|
|
47
|
+
assert.equal(classifyValue("16px"), "length");
|
|
48
|
+
assert.equal(classifyValue("0.875em"), "length");
|
|
49
|
+
assert.equal(classifyValue("inherit"), "other");
|
|
50
|
+
assert.equal(classifyValue("var(--x)"), "other");
|
|
51
|
+
assert.equal(classifyValue("100%"), "length");
|
|
52
|
+
assert.equal(classifyValue("100vw"), "length");
|
|
53
|
+
assert.equal(classifyValue("0"), "length");
|
|
54
|
+
assert.equal(classifyValue("2pt"), "length");
|
|
55
|
+
assert.equal(classifyValue("#abc12"), "other");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("filterTokens is a case-insensitive substring match; blank query passes all", () => {
|
|
59
|
+
const decls = [
|
|
60
|
+
{ name: "--text-accent", value: "x" },
|
|
61
|
+
{ name: "--size-4-2", value: "y" },
|
|
62
|
+
];
|
|
63
|
+
assert.deepEqual(filterTokens(decls, "ACCENT"), [decls[0]]);
|
|
64
|
+
assert.deepEqual(filterTokens(decls, " "), decls);
|
|
65
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildStoryTree,
|
|
5
|
+
kebabCase,
|
|
6
|
+
storyMetaFromGlobKey,
|
|
7
|
+
titleFromSlug,
|
|
8
|
+
} from "../src/viewer/derive.ts";
|
|
9
|
+
|
|
10
|
+
test("kebabCase converts PascalCase and spaces/underscores", () => {
|
|
11
|
+
assert.equal(kebabCase("SettingsPanel"), "settings-panel");
|
|
12
|
+
assert.equal(kebabCase("Toggle"), "toggle");
|
|
13
|
+
assert.equal(kebabCase("message_feed thing"), "message-feed-thing");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("titleFromSlug start-cases", () => {
|
|
17
|
+
assert.equal(titleFromSlug("settings-panel"), "Settings Panel");
|
|
18
|
+
assert.equal(titleFromSlug("toggle"), "Toggle");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("storyMetaFromGlobKey derives slug + repo-relative paths", () => {
|
|
22
|
+
const meta = storyMetaFromGlobKey("../components/SettingsPanel.stories.ts");
|
|
23
|
+
assert.equal(meta.slug, "settings-panel");
|
|
24
|
+
assert.equal(meta.storiesPath, "src/components/SettingsPanel.stories.ts");
|
|
25
|
+
assert.equal(meta.componentPath, "src/components/SettingsPanel.ts");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("storyMetaFromGlobKey handles nested directories", () => {
|
|
29
|
+
const meta = storyMetaFromGlobKey("../components/chat/MessageFeed.stories.ts");
|
|
30
|
+
assert.equal(meta.slug, "message-feed");
|
|
31
|
+
assert.equal(meta.storiesPath, "src/components/chat/MessageFeed.stories.ts");
|
|
32
|
+
assert.equal(meta.componentPath, "src/components/chat/MessageFeed.ts");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("buildStoryTree nests children under parents", () => {
|
|
36
|
+
const { roots, unknownChildren } = buildStoryTree([
|
|
37
|
+
{ slug: "settings-panel", children: ["toggle"] },
|
|
38
|
+
{ slug: "toggle" },
|
|
39
|
+
]);
|
|
40
|
+
assert.equal(unknownChildren.length, 0);
|
|
41
|
+
assert.equal(roots.length, 1);
|
|
42
|
+
assert.equal(roots[0].slug, "settings-panel");
|
|
43
|
+
assert.equal(roots[0].children[0].slug, "toggle");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("buildStoryTree reports unknown children and keeps them out of the tree", () => {
|
|
47
|
+
const { roots, unknownChildren } = buildStoryTree([{ slug: "a", children: ["ghost"] }]);
|
|
48
|
+
assert.deepEqual(unknownChildren, [{ parent: "a", child: "ghost" }]);
|
|
49
|
+
assert.equal(roots[0].children.length, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("buildStoryTree guards cycles: mutual refs fall back to flat roots", () => {
|
|
53
|
+
const { roots } = buildStoryTree([
|
|
54
|
+
{ slug: "a", children: ["b"] },
|
|
55
|
+
{ slug: "b", children: ["a"] },
|
|
56
|
+
]);
|
|
57
|
+
// both are referenced, so no natural roots — fall back to all items flat
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
roots.map((r) => r.slug),
|
|
60
|
+
["a", "b"]
|
|
61
|
+
);
|
|
62
|
+
// and recursion must not loop forever: nested child stops at the cycle
|
|
63
|
+
assert.equal(roots[0].children[0].slug, "b");
|
|
64
|
+
assert.equal(roots[0].children[0].children.length, 0);
|
|
65
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { defineStories, normalizeVariants, validateStoryDef } from "../src/viewer/stories.ts";
|
|
4
|
+
|
|
5
|
+
test("defineStories is an identity that preserves the def", () => {
|
|
6
|
+
const def = { variants: { default: () => "x" } };
|
|
7
|
+
assert.equal(defineStories(def), def);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("validateStoryDef accepts a minimal valid def", () => {
|
|
11
|
+
assert.deepEqual(validateStoryDef({ variants: { default: () => "x" } }), { ok: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("validateStoryDef accepts full metadata incl. componentPath override", () => {
|
|
15
|
+
const def = {
|
|
16
|
+
title: "Toggle",
|
|
17
|
+
description: "d",
|
|
18
|
+
componentPath: "src/components/SettingsPanel.ts",
|
|
19
|
+
variants: { on: { render: () => "x", notes: "n" } },
|
|
20
|
+
children: ["other"],
|
|
21
|
+
};
|
|
22
|
+
assert.deepEqual(validateStoryDef(def), { ok: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("validateStoryDef rejects non-objects and missing/empty variants", () => {
|
|
26
|
+
assert.equal(validateStoryDef(undefined).ok, false);
|
|
27
|
+
assert.equal(validateStoryDef(null).ok, false);
|
|
28
|
+
assert.equal(validateStoryDef({}).ok, false);
|
|
29
|
+
assert.equal(validateStoryDef({ variants: {} }).ok, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("validateStoryDef rejects bad variant values and bad children", () => {
|
|
33
|
+
assert.equal(validateStoryDef({ variants: { a: 42 } }).ok, false);
|
|
34
|
+
assert.equal(validateStoryDef({ variants: { a: { notes: "no render" } } }).ok, false);
|
|
35
|
+
assert.equal(validateStoryDef({ variants: { a: () => "x" }, children: [1] }).ok, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("normalizeVariants wraps bare functions and passes objects through", () => {
|
|
39
|
+
const fn = () => "x";
|
|
40
|
+
const out = normalizeVariants({ bare: fn, full: { render: fn, notes: "n" } });
|
|
41
|
+
assert.deepEqual(out.bare, { render: fn });
|
|
42
|
+
assert.equal(out.full.render, fn);
|
|
43
|
+
assert.equal(out.full.notes, "n");
|
|
44
|
+
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { html } from "@arrow-js/core";
|
|
2
|
-
import type { ArrowTemplate } from "@arrow-js/core";
|
|
3
|
-
import type { Example } from "./registry";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Landing page at "/" — an Obsidian-styled list of the available example
|
|
7
|
-
* components, each linking to its own route. Plain anchors (full reloads) keep
|
|
8
|
-
* the router trivial; the sandbox doesn't need SPA navigation.
|
|
9
|
-
*/
|
|
10
|
-
export const ExamplesIndex = (items: Example[]): ArrowTemplate => html`
|
|
11
|
-
<div class="oas-settings">
|
|
12
|
-
<div class="setting-item setting-item-heading">
|
|
13
|
-
<div class="setting-item-info">
|
|
14
|
-
<div class="setting-item-name">Examples</div>
|
|
15
|
-
<div class="setting-item-description">
|
|
16
|
-
Component demos rendered with real Obsidian styling.
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
19
|
-
</div>
|
|
20
|
-
${items.map((example) =>
|
|
21
|
-
html`
|
|
22
|
-
<div class="setting-item">
|
|
23
|
-
<div class="setting-item-info">
|
|
24
|
-
<div class="setting-item-name">
|
|
25
|
-
<a href="${example.path}">${example.label}</a>
|
|
26
|
-
</div>
|
|
27
|
-
<div class="setting-item-description">${example.description}</div>
|
|
28
|
-
</div>
|
|
29
|
-
<div class="setting-item-control">
|
|
30
|
-
<a class="mod-cta oas-open-link" href="${example.path}">Open</a>
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
33
|
-
`.key(example.path)
|
|
34
|
-
)}
|
|
35
|
-
</div>
|
|
36
|
-
`;
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { ArrowExpression } from "@arrow-js/core";
|
|
2
|
-
import { SettingsPanel } from "../components/SettingsPanel";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Registry of example components, keyed by route path. Add a new demo here and
|
|
6
|
-
* it shows up on the index page and at its own path automatically.
|
|
7
|
-
*/
|
|
8
|
-
export interface Example {
|
|
9
|
-
path: string;
|
|
10
|
-
label: string;
|
|
11
|
-
description: string;
|
|
12
|
-
view: () => ArrowExpression;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const examples: Example[] = [
|
|
16
|
-
{
|
|
17
|
-
path: "/example",
|
|
18
|
-
label: "Settings panel",
|
|
19
|
-
description: "Vertical tabs, toggles, a keyed list, and an async boundary() section.",
|
|
20
|
-
view: () => SettingsPanel(),
|
|
21
|
-
},
|
|
22
|
-
];
|
|
23
|
-
|
|
24
|
-
export function findExample(path: string): Example | undefined {
|
|
25
|
-
return examples.find((example) => example.path === path);
|
|
26
|
-
}
|