dekit-cli 0.1.0 → 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.
- package/README.md +24 -1
- package/bin/dekit.ts +98 -34
- package/dist/editor/assets/{index-DK9iczjI.js → index-yt1AtkL3.js} +6 -6
- package/dist/editor/index.html +1 -1
- package/docs/usage.md +127 -0
- package/editor/src/App.tsx +9 -11
- package/editor/src/types.ts +1 -0
- package/package.json +34 -9
- package/src/chrome.test.ts +24 -0
- package/src/chrome.ts +37 -0
- package/src/cli/add.test.ts +62 -0
- package/src/cli/add.ts +104 -0
- package/src/cli/init.test.ts +61 -0
- package/src/cli/init.ts +46 -0
- package/src/cli/ls.test.ts +29 -0
- package/src/cli/ls.ts +24 -0
- package/src/cli/resolve.test.ts +43 -0
- package/src/cli/resolve.ts +92 -0
- package/src/cli/screenshot.test.ts +54 -0
- package/src/cli/screenshot.ts +154 -0
- package/src/cli/usage.ts +8 -0
- package/src/devices.ts +23 -0
- package/src/injected/inspector.ts +31 -0
- package/src/parser.test.ts +6 -6
- package/src/parser.ts +2 -2
- package/src/ref.test.ts +68 -0
- package/src/ref.ts +42 -0
- package/src/server.ts +8 -5
- package/src/types.ts +13 -0
- package/src/vite-plugin-dekit.ts +13 -6
- package/templates/components/blank/component.css +3 -0
- package/templates/components/blank/component.html +5 -0
- package/templates/pages/blank/page.css +3 -0
- package/templates/pages/blank/page.html +3 -0
- package/templates/pages/form/page.css +88 -0
- package/templates/pages/form/page.html +21 -0
- package/templates/pages/grid/page.css +84 -0
- package/templates/pages/grid/page.html +40 -0
- package/templates/pages/hero/page.css +62 -0
- package/templates/pages/hero/page.html +10 -0
- package/templates/projects/blank/dekit.yaml +9 -0
- package/templates/projects/blank/global.css +18 -0
- package/templates/projects/blank/pages/home/home.css +3 -0
- package/templates/projects/blank/pages/home/home.html +3 -0
- package/templates/projects/dashboard/dekit.yaml +9 -0
- package/templates/projects/dashboard/global.css +18 -0
- package/templates/projects/dashboard/pages/home/home.css +248 -0
- package/templates/projects/dashboard/pages/home/home.html +100 -0
- package/templates/projects/landing/dekit.yaml +9 -0
- package/templates/projects/landing/global.css +18 -0
- package/templates/projects/landing/pages/home/home.css +255 -0
- package/templates/projects/landing/pages/home/home.html +79 -0
- package/templates/projects/mobile/dekit.yaml +9 -0
- package/templates/projects/mobile/global.css +20 -0
- package/templates/projects/mobile/pages/home/home.css +267 -0
- 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
|
+
}
|
package/src/cli/usage.ts
ADDED
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 }
|
package/src/parser.test.ts
CHANGED
|
@@ -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
|
|
9
|
-
const config = await parseDesignConfig(join(DESIGN_DIR, "
|
|
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, "
|
|
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/
|
|
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, "
|
|
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/
|
|
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
|
|
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
|
|
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
|
package/src/ref.test.ts
ADDED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
package/src/vite-plugin-dekit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|