dekit-cli 0.4.2 → 0.5.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.
@@ -20,10 +20,18 @@ export async function startEditorServer(
20
20
  for (const [name, def] of Object.entries(config.pages)) {
21
21
  pagePaths[name] = join(config.baseDir, def.template);
22
22
  }
23
+ const pageProperties: Record<string, Record<string, { type: string; default: unknown }>> = {};
24
+ for (const [name, def] of Object.entries(config.pages)) {
25
+ if (def.properties) {
26
+ pageProperties[name] = def.properties;
27
+ }
28
+ }
23
29
  const payload = {
24
30
  pages: Object.keys(config.pages),
25
31
  pagePaths,
32
+ pageProperties,
26
33
  components: Object.keys(config.components),
34
+ device: config.device ?? null,
27
35
  designServerUrl: `http://localhost:${designPort}`,
28
36
  };
29
37
  res.setHeader("Content-Type", "application/json");
@@ -55,6 +55,170 @@ pages:
55
55
  ).rejects.toThrow(/not found|does not exist/i);
56
56
  });
57
57
 
58
+ test("parses device as preset name string", async () => {
59
+ const yaml = `
60
+ version: 1.0
61
+ device: "iPhone 16"
62
+ pages: {}
63
+ `;
64
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
65
+ expect(config.device).toEqual({
66
+ width: 393,
67
+ height: 852,
68
+ dpr: 3,
69
+ name: "iPhone 16",
70
+ });
71
+ });
72
+
73
+ test("parses device as custom dimensions object", async () => {
74
+ const yaml = `
75
+ version: 1.0
76
+ device:
77
+ width: 400
78
+ height: 800
79
+ dpr: 2
80
+ pages: {}
81
+ `;
82
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
83
+ expect(config.device).toEqual({
84
+ width: 400,
85
+ height: 800,
86
+ dpr: 2,
87
+ });
88
+ });
89
+
90
+ test("device defaults dpr to 1 when omitted", async () => {
91
+ const yaml = `
92
+ version: 1.0
93
+ device:
94
+ width: 400
95
+ height: 800
96
+ pages: {}
97
+ `;
98
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
99
+ expect(config.device!.dpr).toBe(1);
100
+ });
101
+
102
+ test("device is undefined when not specified", async () => {
103
+ const yaml = `
104
+ version: 1.0
105
+ pages: {}
106
+ `;
107
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
108
+ expect(config.device).toBeUndefined();
109
+ });
110
+
111
+ test("rejects unknown device preset name", async () => {
112
+ const yaml = `
113
+ version: 1.0
114
+ device: "Nokia 3310"
115
+ pages: {}
116
+ `;
117
+ await expect(
118
+ parseDesignConfig("/fake/dekit.yaml", yaml)
119
+ ).rejects.toThrow(/Unknown device/i);
120
+ });
121
+
122
+ test("rejects device object without width/height", async () => {
123
+ const yaml = `
124
+ version: 1.0
125
+ device:
126
+ dpr: 2
127
+ pages: {}
128
+ `;
129
+ await expect(
130
+ parseDesignConfig("/fake/dekit.yaml", yaml)
131
+ ).rejects.toThrow(/width.*height/i);
132
+ });
133
+
134
+ test("parses page properties", async () => {
135
+ const yaml = `
136
+ version: 1.0
137
+ pages:
138
+ home:
139
+ template: "pages/home/home.html"
140
+ style: "pages/home/home.css"
141
+ properties:
142
+ showBanner:
143
+ type: boolean
144
+ default: true
145
+ itemCount:
146
+ type: number
147
+ default: 3
148
+ title:
149
+ type: string
150
+ default: "Hello"
151
+ `;
152
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
153
+ expect(config.pages["home"].properties).toEqual({
154
+ showBanner: { type: "boolean", default: true },
155
+ itemCount: { type: "number", default: 3 },
156
+ title: { type: "string", default: "Hello" },
157
+ });
158
+ });
159
+
160
+ test("page without properties has undefined properties", async () => {
161
+ const yaml = `
162
+ version: 1.0
163
+ pages:
164
+ home:
165
+ template: "pages/home/home.html"
166
+ style: "pages/home/home.css"
167
+ `;
168
+ const config = await parseDesignConfig("/fake/dekit.yaml", yaml);
169
+ expect(config.pages["home"].properties).toBeUndefined();
170
+ });
171
+
172
+ test("rejects invalid property type", async () => {
173
+ const yaml = `
174
+ version: 1.0
175
+ pages:
176
+ home:
177
+ template: "pages/home/home.html"
178
+ style: "pages/home/home.css"
179
+ properties:
180
+ color:
181
+ type: color
182
+ default: red
183
+ `;
184
+ await expect(
185
+ parseDesignConfig("/fake/dekit.yaml", yaml)
186
+ ).rejects.toThrow(/Invalid type.*color/i);
187
+ });
188
+
189
+ test("rejects property with mismatched default type", async () => {
190
+ const yaml = `
191
+ version: 1.0
192
+ pages:
193
+ home:
194
+ template: "pages/home/home.html"
195
+ style: "pages/home/home.css"
196
+ properties:
197
+ count:
198
+ type: number
199
+ default: "not a number"
200
+ `;
201
+ await expect(
202
+ parseDesignConfig("/fake/dekit.yaml", yaml)
203
+ ).rejects.toThrow(/must be a number/i);
204
+ });
205
+
206
+ test("rejects property missing default", async () => {
207
+ const yaml = `
208
+ version: 1.0
209
+ pages:
210
+ home:
211
+ template: "pages/home/home.html"
212
+ style: "pages/home/home.css"
213
+ properties:
214
+ flag:
215
+ type: boolean
216
+ `;
217
+ await expect(
218
+ parseDesignConfig("/fake/dekit.yaml", yaml)
219
+ ).rejects.toThrow(/Missing default/i);
220
+ });
221
+
58
222
  test("rejects missing version field", async () => {
59
223
  const yaml = `
60
224
  global-style: "global.css"
package/src/parser.ts CHANGED
@@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import yaml from "js-yaml";
5
- import type { DesignConfig, ComponentDef, PageDef } from "./types.js";
5
+ import type { DesignConfig, DeviceConfig, ComponentDef, PageDef, PropertyDef, PropertyType } from "./types.js";
6
+ import { DEVICE_PRESETS } from "./devices.js";
6
7
 
7
8
  export async function parseDesignConfig(
8
9
  configPath: string,
@@ -24,6 +25,8 @@ export async function parseDesignConfig(
24
25
  // Validate file paths if the base directory actually exists on disk
25
26
  const shouldValidateFiles = existsSync(baseDir);
26
27
 
28
+ const device = parseDevice(doc.device);
29
+
27
30
  const globalStyle = (doc["global-style"] as string) ?? "";
28
31
  if (globalStyle && shouldValidateFiles) {
29
32
  const globalStylePath = join(baseDir, globalStyle);
@@ -51,24 +54,92 @@ export async function parseDesignConfig(
51
54
  }
52
55
 
53
56
  const rawPages =
54
- (doc.pages as Record<string, { template: string; style: string }>) ?? {};
57
+ (doc.pages as Record<string, { template: string; style: string; properties?: Record<string, unknown> }>) ?? {};
55
58
  const pages: Record<string, PageDef> = {};
56
59
  for (const [key, val] of Object.entries(rawPages)) {
57
60
  if (shouldValidateFiles) {
58
61
  validateFilePaths(baseDir, key, val.template, val.style);
59
62
  }
60
- pages[key] = { template: val.template, style: val.style };
63
+ const properties = val.properties ? parseProperties(key, val.properties) : undefined;
64
+ pages[key] = { template: val.template, style: val.style, properties };
61
65
  }
62
66
 
63
67
  return {
64
68
  version: doc.version as number,
65
69
  globalStyle,
70
+ device,
66
71
  components,
67
72
  pages,
68
73
  baseDir,
69
74
  };
70
75
  }
71
76
 
77
+ function parseDevice(raw: unknown): DeviceConfig | undefined {
78
+ if (raw == null) return undefined;
79
+
80
+ if (typeof raw === "string") {
81
+ const normalizedInput = raw.toLowerCase().replace(/\s+/g, "-");
82
+ const preset = DEVICE_PRESETS.find(
83
+ (d) => d.name.toLowerCase().replace(/\s+/g, "-") === normalizedInput
84
+ );
85
+ if (!preset) {
86
+ throw new Error(
87
+ `Unknown device: "${raw}". Available: ${DEVICE_PRESETS.map((d) => d.name).join(", ")}`
88
+ );
89
+ }
90
+ return { width: preset.width, height: preset.height, dpr: preset.dpr, name: preset.name };
91
+ }
92
+
93
+ if (typeof raw === "object") {
94
+ const obj = raw as Record<string, unknown>;
95
+ const width = obj.width;
96
+ const height = obj.height;
97
+ if (typeof width !== "number" || typeof height !== "number") {
98
+ throw new Error("Device config must have numeric 'width' and 'height'");
99
+ }
100
+ const dpr = typeof obj.dpr === "number" ? obj.dpr : 1;
101
+ return { width, height, dpr };
102
+ }
103
+
104
+ throw new Error("Invalid device config: expected a device name string or {width, height, dpr} object");
105
+ }
106
+
107
+ const VALID_PROPERTY_TYPES: PropertyType[] = ["boolean", "number", "string"];
108
+
109
+ function parseProperties(
110
+ pageKey: string,
111
+ raw: Record<string, unknown>
112
+ ): Record<string, PropertyDef> {
113
+ const result: Record<string, PropertyDef> = {};
114
+ for (const [name, def] of Object.entries(raw)) {
115
+ if (!def || typeof def !== "object") {
116
+ throw new Error(
117
+ `Invalid property "${name}" on page "${pageKey}": expected {type, default}`
118
+ );
119
+ }
120
+ const obj = def as Record<string, unknown>;
121
+ const type = obj.type as string;
122
+ if (!VALID_PROPERTY_TYPES.includes(type as PropertyType)) {
123
+ throw new Error(
124
+ `Invalid type "${type}" for property "${name}" on page "${pageKey}". Must be: ${VALID_PROPERTY_TYPES.join(", ")}`
125
+ );
126
+ }
127
+ if (obj.default == null) {
128
+ throw new Error(
129
+ `Missing default for property "${name}" on page "${pageKey}"`
130
+ );
131
+ }
132
+ const expectedType = type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
133
+ if (typeof obj.default !== expectedType) {
134
+ throw new Error(
135
+ `Default value for "${name}" on page "${pageKey}" must be a ${type}`
136
+ );
137
+ }
138
+ result[name] = { type: type as PropertyType, default: obj.default };
139
+ }
140
+ return result;
141
+ }
142
+
72
143
  function validateFilePaths(
73
144
  baseDir: string,
74
145
  key: string,
package/src/types.ts CHANGED
@@ -3,14 +3,30 @@ export interface ComponentDef {
3
3
  style: string;
4
4
  }
5
5
 
6
+ export type PropertyType = "boolean" | "number" | "string";
7
+
8
+ export interface PropertyDef {
9
+ type: PropertyType;
10
+ default: unknown;
11
+ }
12
+
6
13
  export interface PageDef {
7
14
  template: string;
8
15
  style: string;
16
+ properties?: Record<string, PropertyDef>;
17
+ }
18
+
19
+ export interface DeviceConfig {
20
+ width: number;
21
+ height: number;
22
+ dpr: number;
23
+ name?: string;
9
24
  }
10
25
 
11
26
  export interface DesignConfig {
12
27
  version: number;
13
28
  globalStyle: string;
29
+ device?: DeviceConfig;
14
30
  components: Record<string, ComponentDef>;
15
31
  pages: Record<string, PageDef>;
16
32
  baseDir: string; // absolute path to .dekit/ directory (where dekit.yaml lives)
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { assemblePageHtml } from "./vite-plugin-dekit.js";
2
+ import { assemblePageHtml, resolvePageProps } from "./vite-plugin-dekit.js";
3
3
  import type { DesignConfig } from "./types.js";
4
4
  import { join } from "node:path";
5
5
 
@@ -59,4 +59,88 @@ describe("assemblePageHtml", () => {
59
59
  expect(html).toContain("<slot></slot>");
60
60
  expect(html).not.toMatch(/innerHTML\s*=\s*`\s*<template>/);
61
61
  });
62
+
63
+ test("injects __DEKIT_PROPS__ when page has properties", async () => {
64
+ const configWithProps: DesignConfig = {
65
+ ...mockConfig,
66
+ pages: {
67
+ home: {
68
+ ...mockConfig.pages["home"],
69
+ properties: {
70
+ showBanner: { type: "boolean", default: true },
71
+ count: { type: "number", default: 3 },
72
+ },
73
+ },
74
+ },
75
+ };
76
+ const html = await assemblePageHtml(configWithProps, "home");
77
+ expect(html).toContain("window.__DEKIT_PROPS__");
78
+ expect(html).toContain('"showBanner":true');
79
+ expect(html).toContain('"count":3');
80
+ });
81
+
82
+ test("does not inject __DEKIT_PROPS__ when page has no properties", async () => {
83
+ const html = await assemblePageHtml(mockConfig, "home");
84
+ expect(html).not.toContain("__DEKIT_PROPS__");
85
+ });
86
+
87
+ test("props override applies over defaults", async () => {
88
+ const configWithProps: DesignConfig = {
89
+ ...mockConfig,
90
+ pages: {
91
+ home: {
92
+ ...mockConfig.pages["home"],
93
+ properties: {
94
+ showBanner: { type: "boolean", default: true },
95
+ count: { type: "number", default: 3 },
96
+ },
97
+ },
98
+ },
99
+ };
100
+ const html = await assemblePageHtml(configWithProps, "home", {
101
+ props: { showBanner: "false", count: "10" },
102
+ });
103
+ expect(html).toContain('"showBanner":false');
104
+ expect(html).toContain('"count":10');
105
+ });
106
+ });
107
+
108
+ describe("resolvePageProps", () => {
109
+ test("returns defaults when no overrides", () => {
110
+ const result = resolvePageProps({
111
+ properties: {
112
+ flag: { type: "boolean", default: true },
113
+ num: { type: "number", default: 5 },
114
+ str: { type: "string", default: "hi" },
115
+ },
116
+ });
117
+ expect(result).toEqual({ flag: true, num: 5, str: "hi" });
118
+ });
119
+
120
+ test("overrides convert types based on schema", () => {
121
+ const result = resolvePageProps(
122
+ {
123
+ properties: {
124
+ flag: { type: "boolean", default: true },
125
+ num: { type: "number", default: 5 },
126
+ str: { type: "string", default: "hi" },
127
+ },
128
+ },
129
+ { flag: "false", num: "42", str: "world" }
130
+ );
131
+ expect(result).toEqual({ flag: false, num: 42, str: "world" });
132
+ });
133
+
134
+ test("ignores unknown override keys", () => {
135
+ const result = resolvePageProps(
136
+ { properties: { flag: { type: "boolean", default: true } } },
137
+ { flag: "false", unknown: "value" }
138
+ );
139
+ expect(result).toEqual({ flag: false });
140
+ });
141
+
142
+ test("returns empty object when no properties defined", () => {
143
+ expect(resolvePageProps({})).toEqual({});
144
+ expect(resolvePageProps({ properties: undefined })).toEqual({});
145
+ });
62
146
  });
@@ -5,10 +5,38 @@ import { join } from "node:path";
5
5
  import { generateComponentRegistrationScript } from "./injected/register-components.js";
6
6
  import { generateInspectorScript } from "./injected/inspector.js";
7
7
 
8
+ export function resolvePageProps(
9
+ pageDef: { properties?: Record<string, { type: string; default: unknown }> },
10
+ overrides?: Record<string, string>
11
+ ): Record<string, unknown> {
12
+ const props: Record<string, unknown> = {};
13
+ if (!pageDef.properties) return props;
14
+
15
+ for (const [key, def] of Object.entries(pageDef.properties)) {
16
+ props[key] = def.default;
17
+ }
18
+
19
+ if (overrides) {
20
+ for (const [key, raw] of Object.entries(overrides)) {
21
+ const def = pageDef.properties[key];
22
+ if (!def) continue;
23
+ if (def.type === "boolean") {
24
+ props[key] = raw === "true";
25
+ } else if (def.type === "number") {
26
+ props[key] = Number(raw);
27
+ } else {
28
+ props[key] = raw;
29
+ }
30
+ }
31
+ }
32
+
33
+ return props;
34
+ }
35
+
8
36
  export async function assemblePageHtml(
9
37
  config: DesignConfig,
10
38
  pageName: string,
11
- options?: { inspector?: boolean }
39
+ options?: { inspector?: boolean; props?: Record<string, string> }
12
40
  ): Promise<string> {
13
41
  const pageDef = config.pages[pageName];
14
42
  if (!pageDef) {
@@ -29,6 +57,11 @@ export async function assemblePageHtml(
29
57
  ? `<script>\n${generateInspectorScript()}\n</script>`
30
58
  : "";
31
59
 
60
+ const resolvedProps = resolvePageProps(pageDef, options?.props);
61
+ const propsBlock = Object.keys(resolvedProps).length > 0
62
+ ? `<script>window.__DEKIT_PROPS__ = ${JSON.stringify(resolvedProps)};</script>\n`
63
+ : "";
64
+
32
65
  return `<!DOCTYPE html>
33
66
  <html>
34
67
  <head>
@@ -39,7 +72,7 @@ export async function assemblePageHtml(
39
72
  <link rel="stylesheet" href="/@design/${pageDef.style}">
40
73
  </head>
41
74
  <body>
42
- ${pageTemplate}
75
+ ${propsBlock}${pageTemplate}
43
76
  <script type="module">
44
77
  ${componentScript}
45
78
  </script>
@@ -85,9 +118,16 @@ export function dekitPlugin(configRef: { current: DesignConfig }): Plugin {
85
118
  const pageName = pageMatch[1];
86
119
  const urlObj = new URL(url, "http://localhost");
87
120
  const noInspector = urlObj.searchParams.has("noinspector");
121
+ const props: Record<string, string> = {};
122
+ for (const [key, value] of urlObj.searchParams.entries()) {
123
+ if (key.startsWith("props.")) {
124
+ props[key.slice(6)] = value;
125
+ }
126
+ }
88
127
  try {
89
128
  const html = await assemblePageHtml(configRef.current, pageName, {
90
129
  inspector: !noInspector,
130
+ props: Object.keys(props).length > 0 ? props : undefined,
91
131
  });
92
132
  res.setHeader("Content-Type", "text/html");
93
133
  res.end(html);
@@ -1,4 +1,5 @@
1
1
  version: 1.0
2
+ device: "iPhone 16"
2
3
  global-style: "global.css"
3
4
 
4
5
  components: {}