create-obsidian-arrow 0.2.2 → 0.4.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.
Files changed (45) hide show
  1. package/README.md +10 -7
  2. package/index.mjs +29 -10
  3. package/package.json +1 -1
  4. package/template/AGENTS.md +31 -5
  5. package/template/README.md +47 -5
  6. package/template/_gitignore +3 -0
  7. package/template/docs/prompts/agent-setup.md +26 -13
  8. package/template/docs/prompts/update-existing.md +13 -9
  9. package/template/docs/workflow.md +21 -8
  10. package/template/package.json +6 -2
  11. package/template/pnpm-lock.yaml +197 -0
  12. package/template/src/components/DiffViewer.ts +42 -0
  13. package/template/src/components/SettingsPanel.ts +1 -1
  14. package/template/src/main.ts +4 -3
  15. package/template/src/utilities.css +205 -0
  16. package/template/stories/DiffViewer.stories.ts +75 -0
  17. package/template/stories/SettingsPanel.stories.ts +11 -0
  18. package/template/stories/Toggle.stories.ts +28 -0
  19. package/template/test/token-utils.test.mjs +65 -0
  20. package/template/test/viewer-derive.test.mjs +65 -0
  21. package/template/test/viewer-stories.test.mjs +44 -0
  22. package/template/{src → tools}/router/client.ts +15 -2
  23. package/template/tools/router/routeToPage.ts +104 -0
  24. package/template/{src → tools}/sandbox/home.ts +55 -26
  25. package/template/tools/sandbox/sandbox.css +474 -0
  26. package/template/tools/viewer/ClassesPage.ts +37 -0
  27. package/template/tools/viewer/ComponentsIndex.ts +56 -0
  28. package/template/tools/viewer/StoryPage.ts +73 -0
  29. package/template/tools/viewer/TokensPage.ts +82 -0
  30. package/template/tools/viewer/derive.ts +91 -0
  31. package/template/tools/viewer/discovery.ts +64 -0
  32. package/template/tools/viewer/obsidian-classes.ts +269 -0
  33. package/template/tools/viewer/sidebar.ts +55 -0
  34. package/template/tools/viewer/stories.ts +83 -0
  35. package/template/tools/viewer/token-utils.ts +84 -0
  36. package/template/tools/viewer/tokens.ts +30 -0
  37. package/template/src/examples/ExamplesIndex.ts +0 -36
  38. package/template/src/examples/registry.ts +0 -26
  39. package/template/src/router/routeToPage.ts +0 -57
  40. package/template/src/sandbox/sandbox.css +0 -130
  41. /package/template/{src → tools}/sandbox/frame.ts +0 -0
  42. /package/template/{src → tools}/sandbox/layout.ts +0 -0
  43. /package/template/{src → tools}/sandbox/shell.ts +0 -0
  44. /package/template/{src → tools}/sandbox/theme.ts +0 -0
  45. /package/template/{src → tools}/sandbox/toolbar.ts +0 -0
@@ -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 "../tools/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 "../tools/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("../../stories/SettingsPanel.stories.ts");
23
+ assert.equal(meta.slug, "settings-panel");
24
+ assert.equal(meta.storiesPath, "stories/SettingsPanel.stories.ts");
25
+ assert.equal(meta.componentPath, "src/components/SettingsPanel.ts");
26
+ });
27
+
28
+ test("storyMetaFromGlobKey handles nested directories", () => {
29
+ const meta = storyMetaFromGlobKey("../../stories/chat/MessageFeed.stories.ts");
30
+ assert.equal(meta.slug, "message-feed");
31
+ assert.equal(meta.storiesPath, "stories/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 "../tools/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,5 +1,7 @@
1
+ import { html } from "@arrow-js/core";
1
2
  import { Frame } from "../sandbox/frame";
2
3
  import { Shell } from "../sandbox/shell";
4
+ import type { Page } from "./routeToPage";
3
5
  import { routeToPage } from "./routeToPage";
4
6
 
5
7
  /**
@@ -29,10 +31,21 @@ function getNavigation(): NavigationLike | undefined {
29
31
 
30
32
  export function startRouter(root: HTMLElement): void {
31
33
  const render = (url: string): void => {
32
- const page = routeToPage(url);
34
+ let resolved = routeToPage(url);
35
+ for (let hops = 0; "redirect" in resolved && hops < 3; hops++) {
36
+ window.history.replaceState({}, "", resolved.redirect);
37
+ resolved = routeToPage(resolved.redirect);
38
+ }
39
+ if ("redirect" in resolved) {
40
+ return;
41
+ }
42
+ const page: Page = resolved;
33
43
  document.title = page.title;
34
44
  root.replaceChildren();
35
- Shell(Frame(page.title, page.view))(root);
45
+ const content = page.sidebar
46
+ ? html`${page.sidebar}${Frame(page.title, page.view)}`
47
+ : Frame(page.title, page.view);
48
+ Shell(content)(root);
36
49
  };
37
50
 
38
51
  render(window.location.href);
@@ -0,0 +1,104 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression } from "@arrow-js/core";
3
+ import { Home } from "../sandbox/home";
4
+ import { ClassesPage } from "../viewer/ClassesPage";
5
+ import { ComponentsIndex } from "../viewer/ComponentsIndex";
6
+ import { StoryPage } from "../viewer/StoryPage";
7
+ import { TokensPage } from "../viewer/TokensPage";
8
+ import { findStory } from "../viewer/discovery";
9
+ import { ViewerSidebar } from "../viewer/sidebar";
10
+
11
+ /**
12
+ * Single route resolver, shared by every entry point (Arrow scaffold shape, so
13
+ * a future SSR lane could call it identically). Pages may carry a sidebar
14
+ * (rendered outside the pane) and routes may resolve to a redirect, which the
15
+ * client router applies via history.replaceState.
16
+ */
17
+ export interface Page {
18
+ status: number;
19
+ title: string;
20
+ view: ArrowExpression;
21
+ sidebar?: ArrowExpression;
22
+ }
23
+
24
+ export interface Redirect {
25
+ redirect: string;
26
+ }
27
+
28
+ const APP_NAME = "Arrow Sandbox";
29
+
30
+ function notFound(pathname: string): Page {
31
+ return {
32
+ status: 404,
33
+ title: `Not found · ${APP_NAME}`,
34
+ view: html`
35
+ <div class="oas-settings">
36
+ <div class="setting-item setting-item-heading">
37
+ <div class="setting-item-info">
38
+ <div class="setting-item-name">Not found</div>
39
+ <div class="setting-item-description">
40
+ No route for <code>${pathname}</code>. <a href="/">Back home</a>.
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ `,
46
+ };
47
+ }
48
+
49
+ export function routeToPage(url: string): Page | Redirect {
50
+ const { pathname, searchParams } = new URL(url, window.location.origin);
51
+
52
+ if (pathname === "/" || pathname === "") {
53
+ return { status: 200, title: APP_NAME, view: Home() };
54
+ }
55
+
56
+ if (pathname === "/example") {
57
+ return { redirect: "/components/settings-panel" };
58
+ }
59
+
60
+ if (pathname === "/components" || pathname === "/components/") {
61
+ return {
62
+ status: 200,
63
+ title: `Components · ${APP_NAME}`,
64
+ view: ComponentsIndex(),
65
+ sidebar: ViewerSidebar(pathname),
66
+ };
67
+ }
68
+
69
+ const storyMatch = pathname.match(/^\/components\/([^/]+)$/);
70
+ if (storyMatch) {
71
+ const story = findStory(storyMatch[1]);
72
+ if (!story) {
73
+ return { ...notFound(pathname), sidebar: ViewerSidebar(pathname) };
74
+ }
75
+ const requested = searchParams.get("variant");
76
+ const variantName = requested ?? Object.keys(story.variants)[0];
77
+ return {
78
+ status: story.variants[variantName] ? 200 : 404,
79
+ title: `${story.title} · ${APP_NAME}`,
80
+ view: StoryPage(story, variantName),
81
+ sidebar: ViewerSidebar(`/components/${story.slug}`),
82
+ };
83
+ }
84
+
85
+ if (pathname === "/reference") {
86
+ return {
87
+ status: 200,
88
+ title: `Tokens · ${APP_NAME}`,
89
+ view: TokensPage(),
90
+ sidebar: ViewerSidebar(pathname),
91
+ };
92
+ }
93
+
94
+ if (pathname === "/reference/classes") {
95
+ return {
96
+ status: 200,
97
+ title: `Classes · ${APP_NAME}`,
98
+ view: ClassesPage(),
99
+ sidebar: ViewerSidebar(pathname),
100
+ };
101
+ }
102
+
103
+ return notFound(pathname);
104
+ }
@@ -1,22 +1,12 @@
1
1
  import { component, html, reactive } from "@arrow-js/core";
2
2
  import type { ArrowTemplate } from "@arrow-js/core";
3
- import { ExamplesIndex } from "../examples/ExamplesIndex";
4
- import { examples } from "../examples/registry";
5
3
  import { layoutState } from "./layout";
6
4
  import { themeState } from "./theme";
7
5
 
8
- /**
9
- * Sandbox landing page at "/": a readiness check + getting-started commands +
10
- * the examples list. Sandbox chrome — does not port to a plugin.
11
- *
12
- * The readiness probe catches the #1 fresh-machine gotcha: running `pnpm dev`
13
- * before `pnpm pull-css` leaves app.css unloaded, so every `var(--…)` token is
14
- * empty. We detect that by reading the computed value of a known token.
15
- */
16
6
  const probe = reactive({ tick: 0 });
17
7
 
18
8
  function stylingLoaded(): boolean {
19
- const generation = probe.tick; // reactive dependency; Re-check bumps it
9
+ const generation = probe.tick;
20
10
  const style = getComputedStyle(document.body);
21
11
  return (
22
12
  generation >= 0 &&
@@ -29,6 +19,8 @@ function recheck(): void {
29
19
  probe.tick++;
30
20
  }
31
21
 
22
+ const gettingStarted = reactive({ expanded: false });
23
+
32
24
  const GETTING_STARTED = [
33
25
  { cmd: "pnpm pull-css", note: "extract Obsidian's app.css — run once (macOS auto-detect)" },
34
26
  { cmd: "pnpm dev", note: "this dev server (Vite + HMR)" },
@@ -36,9 +28,13 @@ const GETTING_STARTED = [
36
28
  { cmd: "pnpm run ci", note: "biome + typecheck + tests + build" },
37
29
  ];
38
30
 
31
+ const VIEWS = [
32
+ { label: "Components", path: "/components", note: "Component story viewer" },
33
+ { label: "Tokens", path: "/reference", note: "CSS custom property reference" },
34
+ { label: "Classes", path: "/reference/classes", note: "Obsidian class catalog" },
35
+ ];
36
+
39
37
  export const Home = component((): ArrowTemplate => {
40
- // Re-probe shortly after mount, in case app.css finished loading after the
41
- // first paint (stylesheets load async).
42
38
  setTimeout(recheck, 250);
43
39
 
44
40
  return html`
@@ -79,28 +75,61 @@ export const Home = component((): ArrowTemplate => {
79
75
  </div>
80
76
  </div>
81
77
  </div>
78
+ </div>
82
79
 
80
+ <div class="${() => (gettingStarted.expanded ? "oas-card is-expanded" : "oas-card")}">
81
+ <div
82
+ class="oas-card-header"
83
+ @click="${() => {
84
+ gettingStarted.expanded = !gettingStarted.expanded;
85
+ }}"
86
+ >
87
+ <span class="oas-card-title">Getting started</span>
88
+ <span class="oas-card-chevron">›</span>
89
+ </div>
90
+ <div class="oas-card-body">
91
+ <div class="oas-settings">
92
+ ${GETTING_STARTED.map((step) =>
93
+ html`
94
+ <div class="setting-item">
95
+ <div class="setting-item-info">
96
+ <div class="setting-item-name" style="font-family: var(--font-monospace);">
97
+ ${step.cmd}
98
+ </div>
99
+ <div class="setting-item-description">${step.note}</div>
100
+ </div>
101
+ </div>
102
+ `.key(step.cmd)
103
+ )}
104
+ </div>
105
+ <p class="oas-card-note">
106
+ See AGENTS.md + docs/ for the full flow; agent prompts in docs/prompts/.
107
+ </p>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="oas-settings">
83
112
  <div class="setting-item setting-item-heading">
84
113
  <div class="setting-item-info">
85
- <div class="setting-item-name">Getting started</div>
86
- <div class="setting-item-description">
87
- See AGENTS.md + docs/ for the full flow; agent prompts in docs/prompts/.
88
- </div>
114
+ <div class="setting-item-name">Views</div>
115
+ <div class="setting-item-description">Main pages in this sandbox.</div>
89
116
  </div>
90
117
  </div>
91
- ${GETTING_STARTED.map((step) =>
118
+ ${VIEWS.map((view) =>
92
119
  html`
93
- <div class="setting-item">
94
- <div class="setting-item-info">
95
- <div class="setting-item-name" style="font-family: var(--font-monospace);">
96
- ${step.cmd}
97
- </div>
98
- <div class="setting-item-description">${step.note}</div>
120
+ <div class="setting-item">
121
+ <div class="setting-item-info">
122
+ <div class="setting-item-name">
123
+ <a href="${view.path}">${view.label}</a>
99
124
  </div>
125
+ <div class="setting-item-description">${view.note}</div>
100
126
  </div>
101
- `.key(step.cmd)
127
+ <div class="setting-item-control">
128
+ <a class="mod-cta oas-open-link" href="${view.path}">Open</a>
129
+ </div>
130
+ </div>
131
+ `.key(view.label)
102
132
  )}
103
133
  </div>
104
- ${ExamplesIndex(examples)}
105
134
  `;
106
135
  });