design-embed 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.
- package/README.md +7 -2
- package/dist/cli.mjs +28 -224
- package/dist/core-BLV62TaX.mjs +907 -0
- package/dist/index.d.mts +185 -312
- package/dist/index.mjs +2 -2
- package/package.json +2 -10
- package/src/cli.ts +7 -32
- package/src/commands/compile.ts +20 -88
- package/src/commands/generateTests.ts +9 -91
- package/src/commands/init.ts +5 -8
- package/src/commands/plugin.ts +3 -14
- package/src/config/index.ts +302 -0
- package/src/core/diagnostics/diagnostic.ts +18 -0
- package/src/core/diagnostics/jsonDiagnostic.ts +51 -0
- package/src/core/index.ts +579 -0
- package/src/core/nodes.ts +74 -0
- package/src/core/pipeline/checkMode.ts +46 -0
- package/src/core/plugins/pluginApi.ts +44 -0
- package/src/core/types.ts +120 -0
- package/src/index.ts +48 -2
- package/src/targets/html.ts +565 -12
- package/dist/src-D3fnqGCq.mjs +0 -511
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Diagnostic } from "./diagnostic.ts";
|
|
2
|
+
|
|
3
|
+
export interface JsonDiagnostic {
|
|
4
|
+
code: string;
|
|
5
|
+
severity: "error" | "warning" | "info";
|
|
6
|
+
message: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
line?: number;
|
|
9
|
+
column?: number;
|
|
10
|
+
details?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toJsonDiagnostic(diagnostic: Diagnostic): JsonDiagnostic {
|
|
14
|
+
const details = {
|
|
15
|
+
...diagnostic.details,
|
|
16
|
+
...(diagnostic.selector ? { selector: diagnostic.selector } : {}),
|
|
17
|
+
...(diagnostic.property ? { property: diagnostic.property } : {}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
code: diagnostic.code,
|
|
22
|
+
severity: diagnostic.severity,
|
|
23
|
+
message: redactSecrets(diagnostic.message),
|
|
24
|
+
...(diagnostic.file ? { file: diagnostic.file } : {}),
|
|
25
|
+
...(diagnostic.source ? { line: diagnostic.source.line } : {}),
|
|
26
|
+
...(diagnostic.source ? { column: diagnostic.source.column } : {}),
|
|
27
|
+
...(Object.keys(details).length > 0 ? { details } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toJsonDiagnostics(diagnostics: Diagnostic[]): JsonDiagnostic[] {
|
|
32
|
+
return diagnostics.map(toJsonDiagnostic);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatDiagnosticText(diagnostic: Diagnostic): string {
|
|
36
|
+
const location = [
|
|
37
|
+
diagnostic.file,
|
|
38
|
+
diagnostic.source?.line,
|
|
39
|
+
diagnostic.source?.column,
|
|
40
|
+
]
|
|
41
|
+
.filter((part) => part !== undefined)
|
|
42
|
+
.join(":");
|
|
43
|
+
const prefix = location ? `${location}: ` : "";
|
|
44
|
+
return `${prefix}${diagnostic.severity}: ${diagnostic.code}: ${redactSecrets(diagnostic.message)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function redactSecrets(value: string): string {
|
|
48
|
+
return value
|
|
49
|
+
.replace(/figma[_-]?token\s*[:=]\s*[^\s]+/gi, "FIGMA_TOKEN=[redacted]")
|
|
50
|
+
.replace(/bearer\s+[a-z0-9._-]+/gi, "Bearer [redacted]");
|
|
51
|
+
}
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { htmlTarget } from "../targets/html.ts";
|
|
5
|
+
import type { Diagnostic } from "./diagnostics/diagnostic.ts";
|
|
6
|
+
import type {
|
|
7
|
+
DesignNode,
|
|
8
|
+
ParsedSelector,
|
|
9
|
+
PropValue,
|
|
10
|
+
SourceLocation,
|
|
11
|
+
} from "./nodes.ts";
|
|
12
|
+
import type { GeneratedFile } from "./plugins/pluginApi.ts";
|
|
13
|
+
import type {
|
|
14
|
+
ComponentMapping,
|
|
15
|
+
DesignEmbedConfig,
|
|
16
|
+
TargetEmitter,
|
|
17
|
+
TargetTestGenerator,
|
|
18
|
+
} from "./types.ts";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
Diagnostic,
|
|
22
|
+
DiagnosticSeverity,
|
|
23
|
+
} from "./diagnostics/diagnostic.ts";
|
|
24
|
+
export type { JsonDiagnostic } from "./diagnostics/jsonDiagnostic.ts";
|
|
25
|
+
export {
|
|
26
|
+
formatDiagnosticText,
|
|
27
|
+
toJsonDiagnostic,
|
|
28
|
+
toJsonDiagnostics,
|
|
29
|
+
} from "./diagnostics/jsonDiagnostic.ts";
|
|
30
|
+
export type {
|
|
31
|
+
DesignNode,
|
|
32
|
+
ParsedSelector,
|
|
33
|
+
PropValue,
|
|
34
|
+
SourceLocation,
|
|
35
|
+
} from "./nodes.ts";
|
|
36
|
+
export type {
|
|
37
|
+
CheckModeInput,
|
|
38
|
+
CheckModeResult,
|
|
39
|
+
} from "./pipeline/checkMode.ts";
|
|
40
|
+
export { checkGeneratedFiles } from "./pipeline/checkMode.ts";
|
|
41
|
+
export type {
|
|
42
|
+
GeneratedAsset,
|
|
43
|
+
GeneratedFile,
|
|
44
|
+
SourcePlugin,
|
|
45
|
+
SourcePluginInput,
|
|
46
|
+
SourcePluginResult,
|
|
47
|
+
TargetEmitResult,
|
|
48
|
+
TargetTestGenerateResult,
|
|
49
|
+
} from "./plugins/pluginApi.ts";
|
|
50
|
+
export type {
|
|
51
|
+
ComponentMapping,
|
|
52
|
+
DesignEmbedConfig,
|
|
53
|
+
NumericTokenGroup,
|
|
54
|
+
StyleMappings,
|
|
55
|
+
StyleMode,
|
|
56
|
+
TargetEmitInput,
|
|
57
|
+
TargetEmitter,
|
|
58
|
+
TargetTestGenerateInput,
|
|
59
|
+
TargetTestGenerator,
|
|
60
|
+
TestAssertions,
|
|
61
|
+
TestGenerationConfig,
|
|
62
|
+
TestState,
|
|
63
|
+
TestViewport,
|
|
64
|
+
TokenConfig,
|
|
65
|
+
} from "./types.ts";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Input for the core embed function.
|
|
69
|
+
*/
|
|
70
|
+
export interface DesignEmbedInput {
|
|
71
|
+
/** The compiler configuration. */
|
|
72
|
+
config?: DesignEmbedConfig;
|
|
73
|
+
/** Working directory. */
|
|
74
|
+
cwd?: string;
|
|
75
|
+
/** When true, skips writing output files to disk. Defaults to false. */
|
|
76
|
+
dryRun?: boolean;
|
|
77
|
+
/** When true, generates test files alongside output files. Defaults to false. */
|
|
78
|
+
generateTests?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Result of the embedding process.
|
|
83
|
+
*/
|
|
84
|
+
export interface DesignEmbedResult {
|
|
85
|
+
/** Source HTML resolved from the config's source plugin. */
|
|
86
|
+
html: string;
|
|
87
|
+
/** Source CSS resolved from the config's source plugin. */
|
|
88
|
+
css?: string;
|
|
89
|
+
/** Generated files. */
|
|
90
|
+
files: GeneratedFile[];
|
|
91
|
+
/** Diagnostics reported during compilation. */
|
|
92
|
+
diagnostics: Diagnostic[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The main compiler entry point.
|
|
97
|
+
* Parses HTML, applies component mappings, and emits files.
|
|
98
|
+
*
|
|
99
|
+
* @param input - The compilation input.
|
|
100
|
+
* @returns A promise resolving to the compilation result.
|
|
101
|
+
*
|
|
102
|
+
*/
|
|
103
|
+
export async function embed(
|
|
104
|
+
input: DesignEmbedInput,
|
|
105
|
+
): Promise<DesignEmbedResult> {
|
|
106
|
+
const cwd = input.cwd ?? process.cwd();
|
|
107
|
+
|
|
108
|
+
if (!input.config?.source) {
|
|
109
|
+
return {
|
|
110
|
+
html: "",
|
|
111
|
+
files: [],
|
|
112
|
+
diagnostics: [
|
|
113
|
+
{
|
|
114
|
+
code: "PLUGIN_REQUIRED",
|
|
115
|
+
message: "Config must include a source plugin.",
|
|
116
|
+
severity: "error",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sourceResult = await input.config.source.run({ cwd });
|
|
123
|
+
const diagnostics = [...sourceResult.diagnostics];
|
|
124
|
+
|
|
125
|
+
if (diagnostics.some((d) => d.severity === "error")) {
|
|
126
|
+
return { html: "", files: [], diagnostics };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!sourceResult.html) {
|
|
130
|
+
return {
|
|
131
|
+
html: "",
|
|
132
|
+
files: [],
|
|
133
|
+
diagnostics: [
|
|
134
|
+
...diagnostics,
|
|
135
|
+
{
|
|
136
|
+
code: "PLUGIN_NO_HTML",
|
|
137
|
+
message: "Source plugin produced no HTML.",
|
|
138
|
+
severity: "error",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const html = sourceResult.html;
|
|
145
|
+
const css = sourceResult.css;
|
|
146
|
+
|
|
147
|
+
const config = patchOutputPaths(input.config as DesignEmbedConfig, cwd);
|
|
148
|
+
|
|
149
|
+
const target = config?.output?.target;
|
|
150
|
+
const targetObj =
|
|
151
|
+
!target || target === "html" ? htmlTarget : (target as TargetEmitter);
|
|
152
|
+
|
|
153
|
+
const mappingDiagnostics = validateComponentMappings(
|
|
154
|
+
config?.components ?? [],
|
|
155
|
+
);
|
|
156
|
+
diagnostics.push(...mappingDiagnostics);
|
|
157
|
+
|
|
158
|
+
if (diagnostics.some((d) => d.severity === "error")) {
|
|
159
|
+
return { html, files: [], diagnostics };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ast = parseHtml(html);
|
|
163
|
+
const mappedNodes = applyComponentMappings(
|
|
164
|
+
ast,
|
|
165
|
+
config?.components ?? [],
|
|
166
|
+
diagnostics,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const { files } = targetObj.emit({
|
|
170
|
+
nodes: mappedNodes,
|
|
171
|
+
css,
|
|
172
|
+
config,
|
|
173
|
+
diagnostics,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (input.generateTests && "generateTests" in targetObj) {
|
|
177
|
+
const testGen = targetObj as unknown as TargetTestGenerator;
|
|
178
|
+
const testResult = testGen.generateTests({ html, css, config });
|
|
179
|
+
diagnostics.push(...testResult.diagnostics);
|
|
180
|
+
if (!diagnostics.some((d) => d.severity === "error")) {
|
|
181
|
+
files.push(...testResult.files);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!input.dryRun) {
|
|
186
|
+
for (const file of files) {
|
|
187
|
+
const outPath = resolve(cwd, file.path);
|
|
188
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
189
|
+
writeFileSync(outPath, file.contents, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { html, css, files, diagnostics };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function patchOutputPaths(
|
|
197
|
+
config: DesignEmbedConfig,
|
|
198
|
+
cwd: string,
|
|
199
|
+
): DesignEmbedConfig {
|
|
200
|
+
const viewsDir = config.output?.viewsDir;
|
|
201
|
+
if (!viewsDir) return config;
|
|
202
|
+
return {
|
|
203
|
+
...config,
|
|
204
|
+
output: { ...config.output, viewsDir: resolveDir(viewsDir, cwd) },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveDir(
|
|
209
|
+
dir: string | URL | undefined,
|
|
210
|
+
cwd: string,
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (!dir) return undefined;
|
|
213
|
+
if (dir instanceof URL) return relative(cwd, fileURLToPath(dir));
|
|
214
|
+
return dir;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function applyComponentMappings(
|
|
218
|
+
nodes: DesignNode[],
|
|
219
|
+
mappings: ComponentMapping[],
|
|
220
|
+
diagnostics: Diagnostic[] = [],
|
|
221
|
+
): DesignNode[] {
|
|
222
|
+
const parsedMappings = mappings
|
|
223
|
+
.map((mapping, index) => ({
|
|
224
|
+
index,
|
|
225
|
+
mapping,
|
|
226
|
+
selector: parseSelector(mapping.selector),
|
|
227
|
+
}))
|
|
228
|
+
.filter(({ selector }) => selector !== undefined) as Array<{
|
|
229
|
+
index: number;
|
|
230
|
+
mapping: ComponentMapping;
|
|
231
|
+
selector: ParsedSelector;
|
|
232
|
+
}>;
|
|
233
|
+
|
|
234
|
+
return nodes.map((node) => {
|
|
235
|
+
if (node.kind !== "element") {
|
|
236
|
+
return node;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const match = parsedMappings.find(({ selector }) =>
|
|
240
|
+
matchesSelector(node, selector),
|
|
241
|
+
);
|
|
242
|
+
if (match) {
|
|
243
|
+
const props = extractProps(node, match.mapping, diagnostics);
|
|
244
|
+
return {
|
|
245
|
+
kind: "component",
|
|
246
|
+
component: match.mapping.component,
|
|
247
|
+
importName: match.mapping.component,
|
|
248
|
+
importPath: `./${match.mapping.component}.view`,
|
|
249
|
+
props,
|
|
250
|
+
children:
|
|
251
|
+
props.children?.kind === "children"
|
|
252
|
+
? undefined
|
|
253
|
+
: applyComponentMappings(
|
|
254
|
+
node.children ?? [],
|
|
255
|
+
mappings,
|
|
256
|
+
diagnostics,
|
|
257
|
+
),
|
|
258
|
+
source: node.source,
|
|
259
|
+
sourceElement: node,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
...node,
|
|
265
|
+
children: applyComponentMappings(
|
|
266
|
+
node.children ?? [],
|
|
267
|
+
mappings,
|
|
268
|
+
diagnostics,
|
|
269
|
+
),
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function parseSelector(selector: string): ParsedSelector | undefined {
|
|
275
|
+
const trimmed = selector.trim();
|
|
276
|
+
if (!trimmed || /[\s>+~,:]/.test(trimmed)) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const parsed: ParsedSelector = { classes: [], attributes: {} };
|
|
281
|
+
let rest = trimmed;
|
|
282
|
+
const tagMatch = rest.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
|
|
283
|
+
if (tagMatch?.[0]) {
|
|
284
|
+
parsed.tagName = tagMatch[0].toLowerCase();
|
|
285
|
+
rest = rest.slice(tagMatch[0].length);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
while (rest) {
|
|
289
|
+
if (rest.startsWith(".")) {
|
|
290
|
+
const match = rest.match(/^\.([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
291
|
+
if (!match?.[1]) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
parsed.classes.push(match[1]);
|
|
295
|
+
rest = rest.slice(match[0].length);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (rest.startsWith("#")) {
|
|
300
|
+
const match = rest.match(/^#([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
301
|
+
if (!match?.[1] || parsed.id) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
parsed.id = match[1];
|
|
305
|
+
rest = rest.slice(match[0].length);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (rest.startsWith("[")) {
|
|
310
|
+
const match = rest.match(
|
|
311
|
+
/^\[([a-zA-Z_][a-zA-Z0-9_.:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/,
|
|
312
|
+
);
|
|
313
|
+
if (!match?.[1]) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
parsed.attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
|
|
317
|
+
rest = rest.slice(match[0].length);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return parsed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function matchesSelector(
|
|
328
|
+
node: DesignNode,
|
|
329
|
+
selector: ParsedSelector,
|
|
330
|
+
): boolean {
|
|
331
|
+
if (node.kind !== "element") {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const attributes = node.attributes ?? {};
|
|
335
|
+
if (selector.tagName && node.tagName !== selector.tagName) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (selector.id && attributes.id !== selector.id) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
const classNames = new Set(
|
|
342
|
+
(attributes.class ?? "").split(/\s+/).filter(Boolean),
|
|
343
|
+
);
|
|
344
|
+
for (const className of selector.classes) {
|
|
345
|
+
if (!classNames.has(className)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const [name, value] of Object.entries(selector.attributes)) {
|
|
350
|
+
if (!(name in attributes)) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (value !== "" && attributes[name] !== value) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function parseHtml(html: string): DesignNode[] {
|
|
362
|
+
const root: DesignNode = {
|
|
363
|
+
kind: "element",
|
|
364
|
+
tagName: "root",
|
|
365
|
+
attributes: {},
|
|
366
|
+
styles: {},
|
|
367
|
+
children: [],
|
|
368
|
+
};
|
|
369
|
+
const stack = [root];
|
|
370
|
+
const tokens = html.matchAll(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^>]*>|[^<]+/g);
|
|
371
|
+
|
|
372
|
+
for (const token of tokens) {
|
|
373
|
+
const value = token[0];
|
|
374
|
+
const offset = token.index;
|
|
375
|
+
const source = getSourceLocation(html, offset);
|
|
376
|
+
|
|
377
|
+
if (value.startsWith("<!--")) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (value.startsWith("</")) {
|
|
382
|
+
const tagName = value.slice(2, -1).trim().toLowerCase();
|
|
383
|
+
while (stack.length > 1) {
|
|
384
|
+
const node = stack.pop();
|
|
385
|
+
if (node?.tagName === tagName) {
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (value.startsWith("<")) {
|
|
393
|
+
const selfClosing = /\/>$/.test(value) || isVoidElement(value);
|
|
394
|
+
const node = parseElement(value, source);
|
|
395
|
+
currentParent(stack).children?.push(node);
|
|
396
|
+
if (!selfClosing) {
|
|
397
|
+
stack.push(node);
|
|
398
|
+
}
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (value.trim()) {
|
|
403
|
+
currentParent(stack).children?.push({
|
|
404
|
+
kind: "text",
|
|
405
|
+
text: collapseWhitespace(value),
|
|
406
|
+
source,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return root.children ?? [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function parseInlineStyle(
|
|
415
|
+
style: string | undefined,
|
|
416
|
+
): Record<string, string> {
|
|
417
|
+
const styles: Record<string, string> = {};
|
|
418
|
+
if (!style) {
|
|
419
|
+
return styles;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const declaration of style.split(";")) {
|
|
423
|
+
const [property, ...valueParts] = declaration.split(":");
|
|
424
|
+
const value = valueParts.join(":").trim();
|
|
425
|
+
if (!property?.trim() || !value) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
styles[property.trim().toLowerCase()] = value;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return styles;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function parseElement(rawTag: string, source: SourceLocation): DesignNode {
|
|
435
|
+
const tagBody = rawTag.replace(/^</, "").replace(/\/?>$/, "").trim();
|
|
436
|
+
const tagName = tagBody.split(/\s+/, 1)[0]?.toLowerCase() ?? "div";
|
|
437
|
+
const attributeSource = tagBody.slice(tagName.length).trim();
|
|
438
|
+
const attributes = parseAttributes(attributeSource);
|
|
439
|
+
const styles = parseInlineStyle(attributes.style);
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
kind: "element",
|
|
443
|
+
tagName,
|
|
444
|
+
attributes,
|
|
445
|
+
styles,
|
|
446
|
+
children: [],
|
|
447
|
+
source,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseAttributes(source: string): Record<string, string> {
|
|
452
|
+
const attributes: Record<string, string> = {};
|
|
453
|
+
const attributePattern =
|
|
454
|
+
/([:@a-zA-Z_][:@a-zA-Z0-9_.-]*)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
455
|
+
|
|
456
|
+
for (const match of source.matchAll(attributePattern)) {
|
|
457
|
+
const name = match[1];
|
|
458
|
+
if (!name) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
attributes[name] = match[3] ?? match[4] ?? match[5] ?? "";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return attributes;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function validateComponentMappings(mappings: ComponentMapping[]): Diagnostic[] {
|
|
469
|
+
const diagnostics: Diagnostic[] = [];
|
|
470
|
+
for (const [index, mapping] of mappings.entries()) {
|
|
471
|
+
if (mapping.selector && !parseSelector(mapping.selector)) {
|
|
472
|
+
diagnostics.push({
|
|
473
|
+
code: "SELECTOR_UNSUPPORTED",
|
|
474
|
+
message: `Component mapping ${index} uses an unsupported selector: ${mapping.selector}`,
|
|
475
|
+
severity: "error",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const [propName, expression] of Object.entries(mapping.props ?? {})) {
|
|
480
|
+
if (!isSupportedPropExpression(expression)) {
|
|
481
|
+
diagnostics.push({
|
|
482
|
+
code: "PROP_EXPRESSION_UNSUPPORTED",
|
|
483
|
+
message: `Component mapping ${index} prop "${propName}" uses an unsupported expression: ${expression}`,
|
|
484
|
+
severity: "error",
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return diagnostics;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function isSupportedPropExpression(expression: string): boolean {
|
|
493
|
+
return (
|
|
494
|
+
!expression.startsWith("$") ||
|
|
495
|
+
expression === "$text" ||
|
|
496
|
+
expression === "$children" ||
|
|
497
|
+
/^\$attr\.[a-zA-Z_][a-zA-Z0-9_.:-]*$/.test(expression)
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function extractProps(
|
|
502
|
+
node: DesignNode,
|
|
503
|
+
mapping: ComponentMapping,
|
|
504
|
+
diagnostics: Diagnostic[],
|
|
505
|
+
): Record<string, PropValue> {
|
|
506
|
+
const props: Record<string, PropValue> = {};
|
|
507
|
+
for (const [propName, expression] of Object.entries(mapping.props ?? {})) {
|
|
508
|
+
if (expression === "$text") {
|
|
509
|
+
props[propName] = { kind: "text", value: collectText(node) };
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (expression === "$children") {
|
|
514
|
+
props[propName] = { kind: "children", value: node.children ?? [] };
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (expression.startsWith("$attr.")) {
|
|
519
|
+
const attributeName = expression.slice("$attr.".length);
|
|
520
|
+
const value = node.attributes?.[attributeName];
|
|
521
|
+
if (value === undefined) {
|
|
522
|
+
diagnostics.push({
|
|
523
|
+
code: "PROP_ATTRIBUTE_MISSING",
|
|
524
|
+
message: `Attribute "${attributeName}" is missing for prop "${propName}".`,
|
|
525
|
+
severity: "warning",
|
|
526
|
+
});
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
props[propName] = {
|
|
530
|
+
kind: "literal",
|
|
531
|
+
value,
|
|
532
|
+
attribute: attributeName,
|
|
533
|
+
};
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
props[propName] = { kind: "literal", value: expression };
|
|
538
|
+
}
|
|
539
|
+
return props;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function collectText(node: DesignNode): string {
|
|
543
|
+
if (node.kind === "text") {
|
|
544
|
+
return node.text ?? "";
|
|
545
|
+
}
|
|
546
|
+
return (node.children ?? [])
|
|
547
|
+
.map((child) => collectText(child))
|
|
548
|
+
.filter(Boolean)
|
|
549
|
+
.join(" ")
|
|
550
|
+
.trim();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function currentParent(stack: DesignNode[]): DesignNode {
|
|
554
|
+
const parent = stack[stack.length - 1];
|
|
555
|
+
if (!parent) {
|
|
556
|
+
throw new Error("HTML parser stack is empty.");
|
|
557
|
+
}
|
|
558
|
+
return parent;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function getSourceLocation(source: string, offset: number): SourceLocation {
|
|
562
|
+
const before = source.slice(0, offset);
|
|
563
|
+
const lines = before.split(/\r?\n/);
|
|
564
|
+
return {
|
|
565
|
+
offset,
|
|
566
|
+
line: lines.length,
|
|
567
|
+
column: (lines[lines.length - 1]?.length ?? 0) + 1,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function collapseWhitespace(value: string): string {
|
|
572
|
+
return value.replace(/\s+/g, " ").trim();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isVoidElement(tag: string): boolean {
|
|
576
|
+
return /^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(\s|>|\/)/i.test(
|
|
577
|
+
tag,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Location in the source HTML file.
|
|
3
|
+
*/
|
|
4
|
+
export interface SourceLocation {
|
|
5
|
+
/** Absolute offset in characters. */
|
|
6
|
+
offset: number;
|
|
7
|
+
/** 1-based line number. */
|
|
8
|
+
line: number;
|
|
9
|
+
/** 1-based column number. */
|
|
10
|
+
column: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A normalized node in the design AST.
|
|
15
|
+
*/
|
|
16
|
+
export interface DesignNode {
|
|
17
|
+
/** The type of node. */
|
|
18
|
+
kind: "element" | "text" | "component";
|
|
19
|
+
/** HTML tag name (for element kind). */
|
|
20
|
+
tagName?: string;
|
|
21
|
+
/** HTML attributes (for element kind). */
|
|
22
|
+
attributes?: Record<string, string>;
|
|
23
|
+
/** Parsed inline styles (for element kind). */
|
|
24
|
+
styles?: Record<string, string>;
|
|
25
|
+
/** Utility classes to apply. */
|
|
26
|
+
generatedClassNames?: string[];
|
|
27
|
+
/** Child nodes. */
|
|
28
|
+
children?: DesignNode[];
|
|
29
|
+
/** Inner text content (for text kind). */
|
|
30
|
+
text?: string;
|
|
31
|
+
/** Original location in the source HTML. */
|
|
32
|
+
source?: SourceLocation;
|
|
33
|
+
/** Component name (for component kind). */
|
|
34
|
+
component?: string;
|
|
35
|
+
/** Named export of the component. */
|
|
36
|
+
importName?: string;
|
|
37
|
+
/** Mapped prop values for the component. */
|
|
38
|
+
props?: Record<string, PropValue>;
|
|
39
|
+
/** Import path of the component. */
|
|
40
|
+
importPath?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The original element node a component was mapped from. Retained so
|
|
43
|
+
* targets can reconstruct the element's structure when emitting the
|
|
44
|
+
* component implementation.
|
|
45
|
+
*/
|
|
46
|
+
sourceElement?: DesignNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A value passed to a component prop.
|
|
51
|
+
*/
|
|
52
|
+
export type PropValue =
|
|
53
|
+
| {
|
|
54
|
+
kind: "literal";
|
|
55
|
+
value: string | number | boolean;
|
|
56
|
+
/** Source attribute name when the prop is bound to `$attr.*`. */
|
|
57
|
+
attribute?: string;
|
|
58
|
+
}
|
|
59
|
+
| { kind: "text"; value: string }
|
|
60
|
+
| { kind: "children"; value: DesignNode[] };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A parsed CSS selector.
|
|
64
|
+
*/
|
|
65
|
+
export interface ParsedSelector {
|
|
66
|
+
/** Optional tag name. */
|
|
67
|
+
tagName?: string;
|
|
68
|
+
/** Optional ID selector. */
|
|
69
|
+
id?: string;
|
|
70
|
+
/** List of class names. */
|
|
71
|
+
classes: string[];
|
|
72
|
+
/** Attribute selectors. */
|
|
73
|
+
attributes: Record<string, string>;
|
|
74
|
+
}
|