dekit-cli 0.1.1 → 0.2.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 (56) hide show
  1. package/README.md +23 -0
  2. package/bin/dekit.ts +98 -34
  3. package/dist/editor/assets/{index-DK9iczjI.js → index-yt1AtkL3.js} +6 -6
  4. package/dist/editor/index.html +1 -1
  5. package/docs/usage.md +127 -0
  6. package/editor/src/App.tsx +9 -11
  7. package/editor/src/types.ts +1 -0
  8. package/package.json +34 -9
  9. package/src/chrome.test.ts +24 -0
  10. package/src/chrome.ts +37 -0
  11. package/src/cli/add.test.ts +62 -0
  12. package/src/cli/add.ts +104 -0
  13. package/src/cli/init.test.ts +61 -0
  14. package/src/cli/init.ts +46 -0
  15. package/src/cli/ls.test.ts +29 -0
  16. package/src/cli/ls.ts +24 -0
  17. package/src/cli/resolve.test.ts +43 -0
  18. package/src/cli/resolve.ts +92 -0
  19. package/src/cli/screenshot.test.ts +54 -0
  20. package/src/cli/screenshot.ts +154 -0
  21. package/src/cli/usage.ts +8 -0
  22. package/src/devices.ts +23 -0
  23. package/src/injected/inspector.ts +31 -0
  24. package/src/parser.test.ts +6 -6
  25. package/src/parser.ts +2 -2
  26. package/src/ref.test.ts +68 -0
  27. package/src/ref.ts +42 -0
  28. package/src/server.ts +8 -5
  29. package/src/types.ts +13 -0
  30. package/src/vite-plugin-dekit.ts +13 -6
  31. package/templates/components/blank/component.css +3 -0
  32. package/templates/components/blank/component.html +5 -0
  33. package/templates/pages/blank/page.css +3 -0
  34. package/templates/pages/blank/page.html +3 -0
  35. package/templates/pages/form/page.css +88 -0
  36. package/templates/pages/form/page.html +21 -0
  37. package/templates/pages/grid/page.css +84 -0
  38. package/templates/pages/grid/page.html +40 -0
  39. package/templates/pages/hero/page.css +62 -0
  40. package/templates/pages/hero/page.html +10 -0
  41. package/templates/projects/blank/dekit.yaml +9 -0
  42. package/templates/projects/blank/global.css +18 -0
  43. package/templates/projects/blank/pages/home/home.css +3 -0
  44. package/templates/projects/blank/pages/home/home.html +3 -0
  45. package/templates/projects/dashboard/dekit.yaml +9 -0
  46. package/templates/projects/dashboard/global.css +18 -0
  47. package/templates/projects/dashboard/pages/home/home.css +248 -0
  48. package/templates/projects/dashboard/pages/home/home.html +100 -0
  49. package/templates/projects/landing/dekit.yaml +9 -0
  50. package/templates/projects/landing/global.css +18 -0
  51. package/templates/projects/landing/pages/home/home.css +255 -0
  52. package/templates/projects/landing/pages/home/home.html +79 -0
  53. package/templates/projects/mobile/dekit.yaml +9 -0
  54. package/templates/projects/mobile/global.css +20 -0
  55. package/templates/projects/mobile/pages/home/home.css +267 -0
  56. package/templates/projects/mobile/pages/home/home.html +110 -0
@@ -0,0 +1,43 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { runInit } from "./init.js";
6
+ import { parseDesignConfig } from "../parser.js";
7
+ import { runResolve } from "./resolve.js";
8
+
9
+ let tempDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = mkdtempSync(join(tmpdir(), "dekit-resolve-"));
13
+ await runInit([tempDir, "--template", "landing"]);
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("runResolve", () => {
21
+ test("resolves page ref to file path", async () => {
22
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
23
+ const spy = vi.spyOn(console, "log");
24
+ await runResolve(config, ["$${home}"]);
25
+ expect(spy.mock.calls[0][0]).toContain("pages/home/home.html");
26
+ spy.mockRestore();
27
+ });
28
+
29
+ test("resolves element ref to file path with line range", async () => {
30
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
31
+ const spy = vi.spyOn(console, "log");
32
+ await runResolve(config, ["$${home@.hero}"]);
33
+ const output = spy.mock.calls[0][0] as string;
34
+ expect(output).toContain("pages/home/home.html");
35
+ expect(output).toMatch(/:\d+(-\d+)?$/);
36
+ spy.mockRestore();
37
+ });
38
+
39
+ test("rejects unknown page", async () => {
40
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
41
+ await expect(runResolve(config, ["$${nonexistent}"])).rejects.toThrow(/not found/i);
42
+ });
43
+ });
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { DesignConfig } from "../types.js";
4
+ import { parseRef } from "../ref.js";
5
+
6
+ export async function runResolve(config: DesignConfig, args: string[]) {
7
+ const refStr = args[0];
8
+ if (!refStr) {
9
+ console.error("Usage: dekit resolve <ref>");
10
+ process.exit(1);
11
+ }
12
+
13
+ const ref = parseRef(refStr);
14
+
15
+ const pageDef = config.pages[ref.pageKey];
16
+ if (!pageDef) {
17
+ throw new Error(`Page "${ref.pageKey}" not found in dekit.yaml`);
18
+ }
19
+
20
+ const templatePath = join(config.baseDir, pageDef.template);
21
+ const relPath = relative(process.cwd(), templatePath);
22
+
23
+ if (ref.type === "page") {
24
+ console.log(relPath);
25
+ return;
26
+ }
27
+
28
+ // Try to find the element in the source HTML by selector heuristic
29
+ const html = readFileSync(templatePath, "utf-8");
30
+ const range = findSelectorInHtml(html, ref.selector);
31
+
32
+ if (range) {
33
+ console.log(`${relPath}:${range.start}-${range.end}`);
34
+ } else {
35
+ console.log(relPath);
36
+ }
37
+ }
38
+
39
+ interface LineRange {
40
+ start: number;
41
+ end: number;
42
+ }
43
+
44
+ function findSelectorInHtml(html: string, selector: string): LineRange | null {
45
+ let searchPattern: RegExp | null = null;
46
+
47
+ if (selector.startsWith("#")) {
48
+ const id = selector.slice(1);
49
+ searchPattern = new RegExp(`id=["']${escapeRegex(id)}["']`);
50
+ } else if (selector.startsWith(".")) {
51
+ const cls = selector.slice(1).split(/[.\s]/)[0];
52
+ searchPattern = new RegExp(`class=["'][^"']*\\b${escapeRegex(cls)}\\b[^"']*["']`);
53
+ } else {
54
+ const tag = selector.split(/[:.\s>\[]/)[0];
55
+ if (tag) {
56
+ searchPattern = new RegExp(`<${escapeRegex(tag)}[\\s>]`);
57
+ }
58
+ }
59
+
60
+ if (!searchPattern) return null;
61
+
62
+ const lines = html.split("\n");
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (searchPattern.test(lines[i])) {
65
+ const startLine = i + 1;
66
+ const endLine = findClosingLine(lines, i);
67
+ return { start: startLine, end: endLine };
68
+ }
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ function findClosingLine(lines: string[], openLine: number): number {
75
+ const openIndent = lines[openLine].search(/\S/);
76
+ for (let i = openLine + 1; i < lines.length; i++) {
77
+ const line = lines[i];
78
+ if (line.trim() === "") continue;
79
+ const indent = line.search(/\S/);
80
+ if (indent <= openIndent && line.trim().startsWith("</")) {
81
+ return i + 1;
82
+ }
83
+ if (indent < openIndent && !line.trim().startsWith("</")) {
84
+ return i;
85
+ }
86
+ }
87
+ return lines.length;
88
+ }
89
+
90
+ function escapeRegex(s: string): string {
91
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { runInit } from "./init.js";
6
+ import { parseDesignConfig } from "../parser.js";
7
+ import { runScreenshot } from "./screenshot.js";
8
+ import { findChrome } from "../chrome.js";
9
+
10
+ let tempDir: string;
11
+ let hasChrome = false;
12
+
13
+ beforeEach(async () => {
14
+ tempDir = mkdtempSync(join(tmpdir(), "dekit-screenshot-"));
15
+ await runInit([tempDir, "--template", "landing"]);
16
+ try {
17
+ await findChrome();
18
+ hasChrome = true;
19
+ } catch {
20
+ hasChrome = false;
21
+ }
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("runScreenshot", () => {
29
+ test.skipIf(!hasChrome)("takes a page screenshot", async () => {
30
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
31
+ await runScreenshot(config, ["$${home}"]);
32
+ expect(existsSync(join(config.baseDir, "screenshots/home.png"))).toBe(true);
33
+ }, 30000);
34
+
35
+ test.skipIf(!hasChrome)("takes element screenshot", async () => {
36
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
37
+ await runScreenshot(config, ["$${home@.hero}"]);
38
+ // Output filename sanitizes the selector
39
+ const screenshots = join(config.baseDir, "screenshots");
40
+ expect(existsSync(screenshots)).toBe(true);
41
+ }, 30000);
42
+
43
+ test.skipIf(!hasChrome)("respects -o flag", async () => {
44
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
45
+ const outPath = join(tempDir, "my-shot.png");
46
+ await runScreenshot(config, ["$${home}", "-o", outPath]);
47
+ expect(existsSync(outPath)).toBe(true);
48
+ }, 30000);
49
+
50
+ test("rejects invalid ref format", async () => {
51
+ const config = await parseDesignConfig(join(tempDir, "dekit.yaml"));
52
+ await expect(runScreenshot(config, ["invalid"])).rejects.toThrow();
53
+ });
54
+ });
@@ -0,0 +1,154 @@
1
+ import type { DesignConfig } from "../types.js";
2
+ import { parseRef } from "../ref.js";
3
+ import { findChrome } from "../chrome.js";
4
+ import { dekitPlugin } from "../vite-plugin-dekit.js";
5
+ import { DEVICE_PRESETS } from "../devices.js";
6
+ import { mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import puppeteer from "puppeteer-core";
9
+ import { createServer } from "vite";
10
+
11
+ function sanitizeSelector(selector: string): string {
12
+ return selector.replace(/[^a-zA-Z0-9_-]/g, "_");
13
+ }
14
+
15
+ function parseArgs(args: string[]): {
16
+ ref: string;
17
+ outputPath?: string;
18
+ deviceName?: string;
19
+ width?: number;
20
+ height?: number;
21
+ fullPage: boolean;
22
+ } {
23
+ let ref: string | undefined;
24
+ let outputPath: string | undefined;
25
+ let deviceName: string | undefined;
26
+ let width: number | undefined;
27
+ let height: number | undefined;
28
+ let fullPage = false;
29
+
30
+ for (let i = 0; i < args.length; i++) {
31
+ const arg = args[i];
32
+ if (arg === "-o" || arg === "--output") {
33
+ outputPath = args[++i];
34
+ } else if (arg === "--device") {
35
+ deviceName = args[++i];
36
+ } else if (arg === "--width") {
37
+ width = parseInt(args[++i], 10);
38
+ } else if (arg === "--height") {
39
+ height = parseInt(args[++i], 10);
40
+ } else if (arg === "--full-page") {
41
+ fullPage = true;
42
+ } else if (!arg.startsWith("-")) {
43
+ ref = arg;
44
+ }
45
+ }
46
+
47
+ if (!ref) {
48
+ throw new Error("Missing ref argument. Expected format: $${page} or $${page@selector}");
49
+ }
50
+
51
+ return { ref, outputPath, deviceName, width, height, fullPage };
52
+ }
53
+
54
+ export async function runScreenshot(config: DesignConfig, args: string[]) {
55
+ const { ref, outputPath, deviceName, width, height, fullPage } = parseArgs(args);
56
+
57
+ // Parse and validate the ref (throws on invalid format)
58
+ const parsedRef = parseRef(ref);
59
+
60
+ // Resolve viewport from --device or --width/--height flags
61
+ let viewportWidth = 1280;
62
+ let viewportHeight = 800;
63
+ let devicePixelRatio = 1;
64
+
65
+ if (deviceName) {
66
+ const normalizedInput = deviceName.toLowerCase().replace(/\s+/g, "-");
67
+ const preset = DEVICE_PRESETS.find(
68
+ (d) => d.name.toLowerCase().replace(/\s+/g, "-") === normalizedInput
69
+ );
70
+ if (!preset) {
71
+ throw new Error(
72
+ `Unknown device: "${deviceName}". Available: ${DEVICE_PRESETS.map((d) => d.name).join(", ")}`
73
+ );
74
+ }
75
+ viewportWidth = preset.width;
76
+ viewportHeight = preset.height;
77
+ devicePixelRatio = preset.dpr;
78
+ }
79
+
80
+ if (width !== undefined) viewportWidth = width;
81
+ if (height !== undefined) viewportHeight = height;
82
+
83
+ // Determine output path
84
+ const screenshotsDir = join(config.baseDir, "screenshots");
85
+ let resolvedOutputPath: string;
86
+
87
+ if (outputPath) {
88
+ resolvedOutputPath = outputPath;
89
+ } else if (parsedRef.type === "page") {
90
+ resolvedOutputPath = join(screenshotsDir, `${parsedRef.pageKey}.png`);
91
+ } else {
92
+ const safeName = `${parsedRef.pageKey}_${sanitizeSelector(parsedRef.selector)}`;
93
+ resolvedOutputPath = join(screenshotsDir, `${safeName}.png`);
94
+ }
95
+
96
+ // Ensure output directory exists
97
+ mkdirSync(dirname(resolvedOutputPath), { recursive: true });
98
+
99
+ // Find Chrome
100
+ const executablePath = await findChrome();
101
+
102
+ // Start a temporary Vite dev server
103
+ const configRef = { current: config };
104
+ const server = await createServer({
105
+ plugins: [dekitPlugin(configRef)],
106
+ server: { port: 0, strictPort: false },
107
+ logLevel: "silent",
108
+ });
109
+ await server.listen();
110
+
111
+ const address = server.httpServer!.address();
112
+ if (!address || typeof address === "string") {
113
+ await server.close();
114
+ throw new Error("Failed to determine Vite server port");
115
+ }
116
+ const port = address.port;
117
+
118
+ // Launch headless Chrome
119
+ const browser = await puppeteer.launch({
120
+ executablePath,
121
+ headless: true,
122
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
123
+ });
124
+
125
+ try {
126
+ const page = await browser.newPage();
127
+ await page.setViewport({
128
+ width: viewportWidth,
129
+ height: viewportHeight,
130
+ deviceScaleFactor: devicePixelRatio,
131
+ });
132
+
133
+ const pageKey = parsedRef.pageKey;
134
+ const url = `http://localhost:${port}/page/${pageKey}?noinspector`;
135
+ await page.goto(url, { waitUntil: "networkidle0" });
136
+
137
+ if (parsedRef.type === "element") {
138
+ const element = await page.$(parsedRef.selector);
139
+ if (!element) {
140
+ throw new Error(
141
+ `Element not found: "${parsedRef.selector}" on page "${pageKey}"`
142
+ );
143
+ }
144
+ await element.screenshot({ path: resolvedOutputPath });
145
+ } else {
146
+ await page.screenshot({ path: resolvedOutputPath, fullPage });
147
+ }
148
+
149
+ console.log(resolvedOutputPath);
150
+ } finally {
151
+ await browser.close();
152
+ await server.close();
153
+ }
154
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export async function runUsage() {
5
+ const guidePath = join(import.meta.dirname, "../../docs/usage.md");
6
+ const content = readFileSync(guidePath, "utf-8");
7
+ console.log(content);
8
+ }
package/src/devices.ts ADDED
@@ -0,0 +1,23 @@
1
+ export interface DevicePreset {
2
+ name: string;
3
+ width: number;
4
+ height: number;
5
+ dpr: number;
6
+ }
7
+
8
+ export const DEVICE_PRESETS: DevicePreset[] = [
9
+ { name: "iPhone SE", width: 375, height: 667, dpr: 2 },
10
+ { name: "iPhone 14", width: 390, height: 844, dpr: 3 },
11
+ { name: "iPhone 14 Pro", width: 393, height: 852, dpr: 3 },
12
+ { name: "iPhone 14 Pro Max", width: 430, height: 932, dpr: 3 },
13
+ { name: "iPhone 16", width: 393, height: 852, dpr: 3 },
14
+ { name: "iPhone 16 Pro Max", width: 440, height: 956, dpr: 3 },
15
+ { name: "iPad Mini", width: 744, height: 1133, dpr: 2 },
16
+ { name: "iPad Air", width: 820, height: 1180, dpr: 2 },
17
+ { name: 'iPad Pro 12.9"', width: 1024, height: 1366, dpr: 2 },
18
+ { name: "Samsung Galaxy S24", width: 360, height: 780, dpr: 3 },
19
+ { name: "Pixel 8", width: 412, height: 915, dpr: 2.625 },
20
+ { name: "Desktop 1280", width: 1280, height: 800, dpr: 1 },
21
+ { name: "Desktop 1440", width: 1440, height: 900, dpr: 1 },
22
+ { name: "Desktop 1920", width: 1920, height: 1080, dpr: 1 },
23
+ ];
@@ -155,6 +155,36 @@ export function generateInspectorScript(): string {
155
155
  return parts.join('/');
156
156
  }
157
157
 
158
+ function generateCssSelector(el) {
159
+ if (el.id) return '#' + el.id;
160
+ if (el.classList && el.classList.length > 0) {
161
+ for (var i = 0; i < el.classList.length; i++) {
162
+ var cls = el.classList[i];
163
+ if (document.querySelectorAll('.' + cls).length === 1) {
164
+ return '.' + cls;
165
+ }
166
+ }
167
+ }
168
+ var parts = [];
169
+ var cur = el;
170
+ while (cur && cur !== document.body && cur !== document.documentElement) {
171
+ var parent = cur.parentElement;
172
+ if (!parent) break;
173
+ var tag = cur.tagName.toLowerCase();
174
+ var siblings = [];
175
+ for (var j = 0; j < parent.children.length; j++) {
176
+ if (parent.children[j].tagName === cur.tagName) siblings.push(parent.children[j]);
177
+ }
178
+ if (siblings.length === 1) {
179
+ parts.unshift(tag);
180
+ } else {
181
+ parts.unshift(tag + ':nth-of-type(' + (siblings.indexOf(cur) + 1) + ')');
182
+ }
183
+ cur = parent;
184
+ }
185
+ return parts.join(' > ');
186
+ }
187
+
158
188
  function getNodeInfo(node) {
159
189
  const computed = getComputedStyle(node);
160
190
  const rect = node.getBoundingClientRect();
@@ -177,6 +207,7 @@ export function generateInspectorScript(): string {
177
207
  return {
178
208
  tagName: node.tagName.toLowerCase(),
179
209
  path: getNodePath(node),
210
+ cssSelector: generateCssSelector(node),
180
211
  attributes: attrs,
181
212
  computedStyles: styles,
182
213
  boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
@@ -5,8 +5,8 @@ import { join } from "node:path";
5
5
  const DESIGN_DIR = join(import.meta.dirname, "../example");
6
6
 
7
7
  describe("parseDesignConfig", () => {
8
- test("parses valid design.yaml", async () => {
9
- const config = await parseDesignConfig(join(DESIGN_DIR, "design.yaml"));
8
+ test("parses valid dekit.yaml", async () => {
9
+ const config = await parseDesignConfig(join(DESIGN_DIR, "dekit.yaml"));
10
10
  expect(config.version).toBe(1.0);
11
11
  expect(config.globalStyle).toBe("global.css");
12
12
  expect(config.components["my-banner"]).toEqual({
@@ -21,7 +21,7 @@ describe("parseDesignConfig", () => {
21
21
  });
22
22
 
23
23
  test("stores resolved base directory", async () => {
24
- const config = await parseDesignConfig(join(DESIGN_DIR, "design.yaml"));
24
+ const config = await parseDesignConfig(join(DESIGN_DIR, "dekit.yaml"));
25
25
  expect(config.baseDir).toBe(DESIGN_DIR);
26
26
  });
27
27
 
@@ -36,7 +36,7 @@ components:
36
36
  pages: {}
37
37
  `;
38
38
  await expect(
39
- parseDesignConfig("/fake/design.yaml", yaml)
39
+ parseDesignConfig("/fake/dekit.yaml", yaml)
40
40
  ).rejects.toThrow(/hyphen/i);
41
41
  });
42
42
 
@@ -51,7 +51,7 @@ pages:
51
51
  style: "pages/cover/cover.css"
52
52
  `;
53
53
  await expect(
54
- parseDesignConfig(join(DESIGN_DIR, "design.yaml"), yaml)
54
+ parseDesignConfig(join(DESIGN_DIR, "dekit.yaml"), yaml)
55
55
  ).rejects.toThrow(/not found|does not exist/i);
56
56
  });
57
57
 
@@ -62,7 +62,7 @@ components: {}
62
62
  pages: {}
63
63
  `;
64
64
  await expect(
65
- parseDesignConfig("/fake/design.yaml", yaml)
65
+ parseDesignConfig("/fake/dekit.yaml", yaml)
66
66
  ).rejects.toThrow(/version/i);
67
67
  });
68
68
  });
package/src/parser.ts CHANGED
@@ -14,11 +14,11 @@ export async function parseDesignConfig(
14
14
  const doc = yaml.load(content) as Record<string, unknown>;
15
15
 
16
16
  if (!doc || typeof doc !== "object") {
17
- throw new Error("Invalid design.yaml: not a valid YAML object");
17
+ throw new Error("Invalid config: not a valid YAML object");
18
18
  }
19
19
 
20
20
  if (doc.version == null) {
21
- throw new Error("Invalid design.yaml: missing 'version' field");
21
+ throw new Error("Invalid config: missing 'version' field");
22
22
  }
23
23
 
24
24
  // Validate file paths if the base directory actually exists on disk
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { parseRef, formatPageRef, formatElementRef } from "./ref.js";
3
+
4
+ describe("parseRef", () => {
5
+ test("parses page ref", () => {
6
+ expect(parseRef("$${home}")).toEqual({ type: "page", pageKey: "home" });
7
+ });
8
+
9
+ test("parses element ref with class selector", () => {
10
+ expect(parseRef("$${home@.hero}")).toEqual({
11
+ type: "element",
12
+ pageKey: "home",
13
+ selector: ".hero",
14
+ });
15
+ });
16
+
17
+ test("parses element ref with id selector", () => {
18
+ expect(parseRef("$${about@#main-nav}")).toEqual({
19
+ type: "element",
20
+ pageKey: "about",
21
+ selector: "#main-nav",
22
+ });
23
+ });
24
+
25
+ test("parses element ref with compound selector", () => {
26
+ expect(parseRef("$${dashboard@.sidebar > .menu}")).toEqual({
27
+ type: "element",
28
+ pageKey: "dashboard",
29
+ selector: ".sidebar > .menu",
30
+ });
31
+ });
32
+
33
+ test("parses element ref with nth-child", () => {
34
+ expect(parseRef("$${about@section:nth-child(2)}")).toEqual({
35
+ type: "element",
36
+ pageKey: "about",
37
+ selector: "section:nth-child(2)",
38
+ });
39
+ });
40
+
41
+ test("throws on invalid format — missing $${ prefix", () => {
42
+ expect(() => parseRef("home")).toThrow();
43
+ });
44
+
45
+ test("throws on invalid format — missing } suffix", () => {
46
+ expect(() => parseRef("$${home")).toThrow();
47
+ });
48
+
49
+ test("throws on empty page key", () => {
50
+ expect(() => parseRef("$${}")).toThrow();
51
+ });
52
+
53
+ test("throws on empty selector after @", () => {
54
+ expect(() => parseRef("$${home@}")).toThrow();
55
+ });
56
+ });
57
+
58
+ describe("formatPageRef", () => {
59
+ test("formats page ref", () => {
60
+ expect(formatPageRef("home")).toBe("$${home}");
61
+ });
62
+ });
63
+
64
+ describe("formatElementRef", () => {
65
+ test("formats element ref", () => {
66
+ expect(formatElementRef("home", ".hero")).toBe("$${home@.hero}");
67
+ });
68
+ });
package/src/ref.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { DekitRef } from "./types.js";
2
+
3
+ const REF_PATTERN = /^\$\$\{(.+)\}$/;
4
+
5
+ export function parseRef(raw: string): DekitRef {
6
+ const match = raw.match(REF_PATTERN);
7
+ if (!match) {
8
+ throw new Error(
9
+ `Invalid ref format: "${raw}". Expected format: \$\${page} or \$\${page@selector}`
10
+ );
11
+ }
12
+
13
+ const inner = match[1];
14
+ const atIndex = inner.indexOf("@");
15
+
16
+ if (atIndex === -1) {
17
+ if (!inner.trim()) {
18
+ throw new Error("Invalid ref: empty page key");
19
+ }
20
+ return { type: "page", pageKey: inner };
21
+ }
22
+
23
+ const pageKey = inner.slice(0, atIndex);
24
+ const selector = inner.slice(atIndex + 1);
25
+
26
+ if (!pageKey.trim()) {
27
+ throw new Error("Invalid ref: empty page key");
28
+ }
29
+ if (!selector.trim()) {
30
+ throw new Error("Invalid ref: empty selector after @");
31
+ }
32
+
33
+ return { type: "element", pageKey, selector };
34
+ }
35
+
36
+ export function formatPageRef(pageKey: string): string {
37
+ return `\$\${${pageKey}}`;
38
+ }
39
+
40
+ export function formatElementRef(pageKey: string, selector: string): string {
41
+ return `\$\${${pageKey}@${selector}}`;
42
+ }
package/src/server.ts CHANGED
@@ -6,6 +6,7 @@ export interface ServerOptions {
6
6
  config: DesignConfig;
7
7
  port: number;
8
8
  designPort: number;
9
+ noOpen?: boolean;
9
10
  }
10
11
 
11
12
  export async function startServers(options: ServerOptions) {
@@ -24,11 +25,13 @@ export async function startServers(options: ServerOptions) {
24
25
  console.log(`\n Open ${editorUrl} in your browser\n`);
25
26
 
26
27
  // Try to open browser
27
- try {
28
- const open = await import("open");
29
- await open.default(editorUrl);
30
- } catch {
31
- // Silently ignore if open fails
28
+ if (!options.noOpen) {
29
+ try {
30
+ const open = await import("open");
31
+ await open.default(editorUrl);
32
+ } catch {
33
+ // Silently ignore if open fails
34
+ }
32
35
  }
33
36
 
34
37
  return { designServer, editorServer };
package/src/types.ts CHANGED
@@ -15,3 +15,16 @@ export interface DesignConfig {
15
15
  pages: Record<string, PageDef>;
16
16
  baseDir: string; // absolute path to directory containing design.yaml
17
17
  }
18
+
19
+ export interface PageRef {
20
+ type: "page";
21
+ pageKey: string;
22
+ }
23
+
24
+ export interface ElementRef {
25
+ type: "element";
26
+ pageKey: string;
27
+ selector: string;
28
+ }
29
+
30
+ export type DekitRef = PageRef | ElementRef;
@@ -7,7 +7,8 @@ import { generateInspectorScript } from "./injected/inspector.js";
7
7
 
8
8
  export async function assemblePageHtml(
9
9
  config: DesignConfig,
10
- pageName: string
10
+ pageName: string,
11
+ options?: { inspector?: boolean }
11
12
  ): Promise<string> {
12
13
  const pageDef = config.pages[pageName];
13
14
  if (!pageDef) {
@@ -22,7 +23,11 @@ export async function assemblePageHtml(
22
23
  config.components,
23
24
  config.baseDir
24
25
  );
25
- const inspectorScript = generateInspectorScript();
26
+
27
+ const includeInspector = options?.inspector !== false;
28
+ const inspectorBlock = includeInspector
29
+ ? `<script>\n${generateInspectorScript()}\n</script>`
30
+ : "";
26
31
 
27
32
  return `<!DOCTYPE html>
28
33
  <html>
@@ -37,9 +42,7 @@ ${pageTemplate}
37
42
  <script type="module">
38
43
  ${componentScript}
39
44
  </script>
40
- <script>
41
- ${inspectorScript}
42
- </script>
45
+ ${inspectorBlock}
43
46
  </body>
44
47
  </html>`;
45
48
  }
@@ -79,8 +82,12 @@ export function dekitPlugin(configRef: { current: DesignConfig }): Plugin {
79
82
  const pageMatch = url.match(/^\/page\/([^/?#]+)/);
80
83
  if (pageMatch) {
81
84
  const pageName = pageMatch[1];
85
+ const urlObj = new URL(url, "http://localhost");
86
+ const noInspector = urlObj.searchParams.has("noinspector");
82
87
  try {
83
- const html = await assemblePageHtml(configRef.current, pageName);
88
+ const html = await assemblePageHtml(configRef.current, pageName, {
89
+ inspector: !noInspector,
90
+ });
84
91
  res.setHeader("Content-Type", "text/html");
85
92
  res.end(html);
86
93
  } catch (err) {