@jxsuite/import 0.34.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/src/emit.ts ADDED
@@ -0,0 +1,231 @@
1
+ import { join, dirname } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import type { JxElement } from "@jxsuite/schema/types";
4
+ import { componentize } from "./componentize.ts";
5
+ import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
6
+
7
+ export interface EmitOptions {
8
+ outDir: string;
9
+ title: string;
10
+ document: JxElement;
11
+ sourceUrl: string;
12
+ /** $media breakpoints to seed into project.json (Phase 1). */
13
+ breakpoints?: Record<string, string>;
14
+ /** Phase 4: componentization options. Pass false to skip. */
15
+ componentizeOptions?: ComponentizeOptions | false;
16
+ }
17
+
18
+ export interface MultiEmitOptions {
19
+ outDir: string;
20
+ title: string;
21
+ sourceUrl: string;
22
+ /** Map of route path (e.g. "pages/index.json") → Jx document. */
23
+ pages: Map<string, JxElement>;
24
+ /** Optional layout to write to layouts/base.json. */
25
+ layout?: JxElement | undefined;
26
+ /** $media breakpoints to seed into project.json. */
27
+ breakpoints?: Record<string, string> | undefined;
28
+ /** Phase 4: componentization options. Pass false to skip. */
29
+ componentizeOptions?: ComponentizeOptions | false | undefined;
30
+ /** Pre-computed componentization result (from AI pass). Overrides componentizeOptions. */
31
+ precomputedComponents?: ComponentizeResult | undefined;
32
+ /** Font-face CSS rule texts to emit as public/assets/fonts.css (R2). */
33
+ fontFaceRules?: string[] | undefined;
34
+ /** URL rewrite map for fonts — maps original font URLs to local paths. */
35
+ fontRewriteMap?: Map<string, string> | undefined;
36
+ /** CSS custom property tokens to hoist into project.json.$style (R5). */
37
+ styleTokens?: Record<string, string> | undefined;
38
+ }
39
+
40
+ export async function emitProject({
41
+ outDir,
42
+ title,
43
+ document,
44
+ sourceUrl,
45
+ breakpoints,
46
+ componentizeOptions,
47
+ }: EmitOptions): Promise<{ files: string[] }> {
48
+ return emitMultiPageProject({
49
+ outDir,
50
+ title,
51
+ sourceUrl,
52
+ pages: new Map([["pages/index.json", document]]),
53
+ breakpoints,
54
+ componentizeOptions,
55
+ });
56
+ }
57
+
58
+ export async function emitMultiPageProject({
59
+ outDir,
60
+ title,
61
+ sourceUrl,
62
+ pages,
63
+ layout,
64
+ breakpoints,
65
+ componentizeOptions,
66
+ precomputedComponents,
67
+ fontFaceRules,
68
+ fontRewriteMap,
69
+ styleTokens,
70
+ }: MultiEmitOptions): Promise<{ files: string[] }> {
71
+ await mkdir(join(outDir, "pages"), { recursive: true });
72
+ await mkdir(join(outDir, "layouts"), { recursive: true });
73
+ await mkdir(join(outDir, "components"), { recursive: true });
74
+ await mkdir(join(outDir, "public"), { recursive: true });
75
+
76
+ const projectJson: Record<string, unknown> = {
77
+ name: title || "Imported Site",
78
+ title: title || "Imported Site",
79
+ description: `Imported from ${sourceUrl}`,
80
+ imports: {},
81
+ images: { optimize: false },
82
+ };
83
+
84
+ if (breakpoints && Object.keys(breakpoints).length > 0) {
85
+ projectJson.$media = breakpoints;
86
+ }
87
+
88
+ if (styleTokens && Object.keys(styleTokens).length > 0) {
89
+ projectJson.$style = styleTokens;
90
+ }
91
+
92
+ const files: string[] = [];
93
+
94
+ // Phase 4: componentize pages
95
+ let finalPages = pages;
96
+ const componentFiles = new Map<string, JxElement>();
97
+
98
+ const compResult =
99
+ precomputedComponents ??
100
+ (componentizeOptions !== false ? componentize(pages, componentizeOptions ?? {}) : null);
101
+
102
+ if (compResult && compResult.components.size > 0) {
103
+ finalPages = compResult.rewrittenPages;
104
+
105
+ for (const [fileName, comp] of compResult.components) {
106
+ const compDoc: JxElement = {
107
+ ...comp.template,
108
+ $id: comp.$id,
109
+ tagName: comp.tagName,
110
+ };
111
+
112
+ const state: Record<string, string> = {};
113
+ extractStateDefaults(comp.template, state);
114
+ if (Object.keys(state).length > 0) {
115
+ compDoc.state = state;
116
+ }
117
+
118
+ componentFiles.set(fileName, compDoc);
119
+ }
120
+ }
121
+
122
+ // R2: Emit @font-face CSS with URLs rewritten to local paths
123
+ if (fontFaceRules && fontFaceRules.length > 0) {
124
+ let fontCss = fontFaceRules.join("\n\n");
125
+ if (fontRewriteMap) {
126
+ for (const [originalUrl, localPath] of fontRewriteMap) {
127
+ // Rewrite absolute URLs in font-face rules to relative local paths
128
+ const relativePath = localPath.startsWith("public/")
129
+ ? localPath.slice("public/".length)
130
+ : localPath;
131
+ fontCss = fontCss.replaceAll(originalUrl, `/${relativePath}`);
132
+ }
133
+ }
134
+ const fontsDir = join(outDir, "public", "assets");
135
+ await mkdir(fontsDir, { recursive: true });
136
+ const fontsCssPath = join(fontsDir, "fonts.css");
137
+ await Bun.write(fontsCssPath, fontCss);
138
+ files.push(fontsCssPath);
139
+
140
+ // Add fonts.css to project head
141
+ if (!projectJson.$head) {
142
+ projectJson.$head = [];
143
+ }
144
+ (projectJson.$head as unknown[]).push({
145
+ tagName: "link",
146
+ attributes: { rel: "stylesheet", href: "/assets/fonts.css" },
147
+ });
148
+ }
149
+
150
+ const projectPath = join(outDir, "project.json");
151
+ await Bun.write(projectPath, `${JSON.stringify(projectJson, null, 2)}\n`);
152
+ files.push(projectPath);
153
+
154
+ // Write components
155
+ for (const [fileName, compDoc] of componentFiles) {
156
+ const compPath = join(outDir, "components", fileName);
157
+ await Bun.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
158
+ files.push(compPath);
159
+ }
160
+
161
+ // Build $elements refs for component registration
162
+ const elementRefs: JxElement[] =
163
+ componentFiles.size > 0
164
+ ? [...componentFiles.keys()].map(
165
+ (f) => ({ $ref: `../components/${f}` }) as unknown as JxElement,
166
+ )
167
+ : [];
168
+
169
+ // Write each page
170
+ for (const [route, doc] of finalPages) {
171
+ const pageDoc = { ...doc };
172
+ if (elementRefs.length > 0) {
173
+ pageDoc.$elements = [
174
+ ...elementRefs,
175
+ ...((pageDoc.$elements as JxElement[]) ?? []),
176
+ ] as JxElement[];
177
+ }
178
+ const pagePath = join(outDir, route);
179
+ await mkdir(dirname(pagePath), { recursive: true });
180
+ await Bun.write(pagePath, `${JSON.stringify(pageDoc, null, 2)}\n`);
181
+ files.push(pagePath);
182
+ }
183
+
184
+ // Write layout
185
+ const layoutPath = join(outDir, "layouts", "base.json");
186
+ const layoutDoc: JxElement = layout ?? {
187
+ tagName: "div",
188
+ children: [{ tagName: "slot" }],
189
+ };
190
+ if (elementRefs.length > 0 && layout) {
191
+ (layoutDoc as Record<string, unknown>).$elements = [
192
+ ...elementRefs,
193
+ ...(((layoutDoc as Record<string, unknown>).$elements as JxElement[]) ?? []),
194
+ ];
195
+ }
196
+ await Bun.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
197
+ files.push(layoutPath);
198
+
199
+ return { files };
200
+ }
201
+
202
+ function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
203
+ if (typeof node === "string") {
204
+ const key = node.match(/^\$\{state\.([^}]+)\}$/)?.[1];
205
+ if (key && !(key in out)) {
206
+ out[key] = "";
207
+ }
208
+ return;
209
+ }
210
+ if (typeof node.textContent === "string") {
211
+ const key = node.textContent.match(/^\$\{state\.([^}]+)\}$/)?.[1];
212
+ if (key && !(key in out)) {
213
+ out[key] = "";
214
+ }
215
+ }
216
+ if (node.attributes) {
217
+ for (const v of Object.values(node.attributes)) {
218
+ if (typeof v === "string") {
219
+ const key = v.match(/^\$\{state\.([^}]+)\}$/)?.[1];
220
+ if (key && !(key in out)) {
221
+ out[key] = "";
222
+ }
223
+ }
224
+ }
225
+ }
226
+ if (Array.isArray(node.children)) {
227
+ for (const child of node.children) {
228
+ extractStateDefaults(child as JxElement | string, out);
229
+ }
230
+ }
231
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ export { capturePage, launchBrowser, closeBrowser } from "./capture.ts";
2
+ export { importSite } from "./run.ts";
3
+ export { convertToJx } from "./to-jx.ts";
4
+ export { emitProject, emitMultiPageProject } from "./emit.ts";
5
+ export { captureStyles, captureStylesAtWidth, STYLE_ALLOWLIST } from "./style-capture.ts";
6
+ export { diffStyles, diffAllStyles, computeMediaDelta, kebabToCamel } from "./style-diff.ts";
7
+ export { analyzeMediaQueries, extractMedia } from "./media-extract.ts";
8
+ export { applyStylesToTree } from "./apply-styles.ts";
9
+ export { collectAssets } from "./asset-collect.ts";
10
+ export { downloadAssets } from "./asset-download.ts";
11
+ export { rewriteAssetUrls } from "./asset-rewrite.ts";
12
+ export { applyTokens } from "./css-tokens.ts";
13
+ export { crawlSite, normalizeUrl, routeToFilePath, fetchRobotsTxt } from "./crawl.ts";
14
+ export { detectLayout, hashSubtree, treesEqual } from "./layout-detect.ts";
15
+ export { componentize } from "./componentize.ts";
16
+ export { aiComponentize } from "./ai-componentize.ts";
17
+ export { diffScreenshots } from "./screenshot-diff.ts";
18
+ export {
19
+ verifyProject,
20
+ captureReferenceScreenshot,
21
+ serveDirectory,
22
+ routeToUrlPath,
23
+ } from "./verify.ts";
24
+ export type { CaptureResult, LaunchOptions } from "./capture.ts";
25
+ export type {
26
+ ImportSiteOptions,
27
+ ImportProgressEvent,
28
+ ImportPhase,
29
+ ImportSiteResult,
30
+ } from "./run.ts";
31
+ export type { ToJxResult } from "./to-jx.ts";
32
+ export type { EmitOptions, MultiEmitOptions } from "./emit.ts";
33
+ export type { CapturedStyle, StyleCaptureResult } from "./style-capture.ts";
34
+ export type { DiffedStyle } from "./style-diff.ts";
35
+ export type { Breakpoint, MediaExtractionResult } from "./media-extract.ts";
36
+ export type {
37
+ DiscoveredAsset,
38
+ AssetCollectionResult,
39
+ CapturedStylesheet,
40
+ } from "./asset-collect.ts";
41
+ export type { CaptureOptions } from "./capture.ts";
42
+ export type { DownloadResult } from "./asset-download.ts";
43
+ export type { TokenExtractionResult } from "./css-tokens.ts";
44
+ export type { CrawlOptions, CrawledPage, CrawlResult } from "./crawl.ts";
45
+ export type { LayoutResult } from "./layout-detect.ts";
46
+ export type {
47
+ ComponentizeOptions,
48
+ ExtractedComponent,
49
+ ComponentizeResult,
50
+ } from "./componentize.ts";
51
+ export type { AiComponentizeOptions } from "./ai-componentize.ts";
52
+ export type { DiffResult, DiffOptions } from "./screenshot-diff.ts";
53
+ export type { VerifyOptions, PageRef, PageVerifyResult, VerifyResult } from "./verify.ts";
@@ -0,0 +1,151 @@
1
+ import type { JxElement } from "@jxsuite/schema/types";
2
+
3
+ export interface LayoutResult {
4
+ /** The shared layout with a $slot placeholder where page-specific content goes. */
5
+ layout: JxElement;
6
+ /** Per-page trees with the shared header/footer removed. */
7
+ strippedPages: Map<string, JxElement>;
8
+ }
9
+
10
+ /**
11
+ * Hash a subtree by structure (tagName + child structure), ignoring text content and attribute
12
+ * values. Used to detect structurally identical subtrees across pages.
13
+ */
14
+ export function hashSubtree(node: JxElement | string): string {
15
+ if (typeof node === "string") {
16
+ return `#text`;
17
+ }
18
+
19
+ const tag = node.tagName ?? "div";
20
+ const childHashes: string[] = [];
21
+
22
+ if (Array.isArray(node.children)) {
23
+ for (const child of node.children) {
24
+ childHashes.push(hashSubtree(child as JxElement | string));
25
+ }
26
+ }
27
+
28
+ if (node.textContent) {
29
+ childHashes.push("#text");
30
+ }
31
+
32
+ return `<${tag}>${childHashes.join(",")}`;
33
+ }
34
+
35
+ /**
36
+ * Compare two Jx trees for deep structural equality (tagName + children structure). Text content
37
+ * and attribute values are compared exactly — this is for detecting truly identical
38
+ * headers/footers, not fuzzy matches.
39
+ */
40
+ export function treesEqual(a: JxElement | string, b: JxElement | string): boolean {
41
+ if (typeof a === "string" && typeof b === "string") {
42
+ return a === b;
43
+ }
44
+ if (typeof a === "string" || typeof b === "string") {
45
+ return false;
46
+ }
47
+
48
+ if ((a.tagName ?? "div") !== (b.tagName ?? "div")) {
49
+ return false;
50
+ }
51
+ if ((a.textContent ?? "") !== (b.textContent ?? "")) {
52
+ return false;
53
+ }
54
+
55
+ const aChildren = Array.isArray(a.children) ? a.children : [];
56
+ const bChildren = Array.isArray(b.children) ? b.children : [];
57
+
58
+ if (aChildren.length !== bChildren.length) {
59
+ return false;
60
+ }
61
+
62
+ for (let i = 0; i < aChildren.length; i++) {
63
+ if (!treesEqual(aChildren[i] as JxElement | string, bChildren[i] as JxElement | string)) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Detect shared header/footer subtrees across multiple pages and extract them into a layout.
73
+ *
74
+ * Strategy: look at the top-level children of each page's root div. Children that appear
75
+ * identically (by deep structural+content equality) at the same position from the start (header) or
76
+ * end (footer) across ALL pages are candidates for the shared layout.
77
+ *
78
+ * Returns null if fewer than 2 pages or no shared subtrees found.
79
+ */
80
+ export function detectLayout(pages: Map<string, JxElement>): LayoutResult | null {
81
+ if (pages.size < 2) {
82
+ return null;
83
+ }
84
+
85
+ const entries = [...pages.entries()];
86
+ const allRoots = entries.map(([, doc]) =>
87
+ Array.isArray(doc.children) ? (doc.children as (JxElement | string)[]) : [],
88
+ );
89
+ const firstRoot = allRoots[0]!;
90
+
91
+ // Find shared prefix (header elements)
92
+ let sharedPrefixLen = 0;
93
+ const minLen = Math.min(...allRoots.map((c) => c.length));
94
+
95
+ for (let i = 0; i < minLen; i++) {
96
+ const reference = firstRoot[i]!;
97
+ const allMatch = allRoots.every((children) => treesEqual(children[i]!, reference));
98
+ if (allMatch) {
99
+ sharedPrefixLen = i + 1;
100
+ } else {
101
+ break;
102
+ }
103
+ }
104
+
105
+ // Find shared suffix (footer elements), not overlapping with prefix
106
+ let sharedSuffixLen = 0;
107
+ for (let i = 0; i < minLen - sharedPrefixLen; i++) {
108
+ const reference = firstRoot[firstRoot.length - 1 - i]!;
109
+ const allMatch = allRoots.every((children) =>
110
+ treesEqual(children[children.length - 1 - i]!, reference),
111
+ );
112
+ if (allMatch) {
113
+ sharedSuffixLen = i + 1;
114
+ } else {
115
+ break;
116
+ }
117
+ }
118
+
119
+ if (sharedPrefixLen === 0 && sharedSuffixLen === 0) {
120
+ return null;
121
+ }
122
+
123
+ // Build layout: shared prefix + $slot + shared suffix
124
+ const headerChildren = firstRoot.slice(0, sharedPrefixLen);
125
+ const footerChildren =
126
+ sharedSuffixLen > 0 ? firstRoot.slice(firstRoot.length - sharedSuffixLen) : [];
127
+
128
+ const slot: JxElement = { tagName: "slot" };
129
+
130
+ const layout: JxElement = {
131
+ tagName: "div",
132
+ children: [...(headerChildren as JxElement[]), slot, ...(footerChildren as JxElement[])],
133
+ };
134
+
135
+ // Strip shared elements from each page
136
+ const strippedPages = new Map<string, JxElement>();
137
+ for (const [route, doc] of entries) {
138
+ const children = Array.isArray(doc.children) ? (doc.children as (JxElement | string)[]) : [];
139
+
140
+ const endIdx = sharedSuffixLen > 0 ? children.length - sharedSuffixLen : children.length;
141
+ const uniqueChildren = children.slice(sharedPrefixLen, endIdx);
142
+
143
+ strippedPages.set(route, {
144
+ ...doc,
145
+ children: uniqueChildren as JxElement[],
146
+ $layout: "layouts/base.json",
147
+ });
148
+ }
149
+
150
+ return { layout, strippedPages };
151
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Extract @media breakpoints from captured CSS and orchestrate style re-capture at each breakpoint
3
+ * width to produce per-node $media style deltas.
4
+ */
5
+
6
+ import type { Page } from "puppeteer-core";
7
+ import type { CapturedStyle } from "./style-capture.ts";
8
+ import { captureStylesAtWidth } from "./style-capture.ts";
9
+ import { computeMediaDelta } from "./style-diff.ts";
10
+ import type { DiffedStyle } from "./style-diff.ts";
11
+
12
+ export interface Breakpoint {
13
+ /** Jx breakpoint name, e.g. "--md" */
14
+ name: string;
15
+ /** CSS media query string, e.g. "(max-width: 768px)" */
16
+ query: string;
17
+ /** The width in px to test this breakpoint at */
18
+ testWidth: number;
19
+ }
20
+
21
+ export interface MediaExtractionResult {
22
+ /** Breakpoints to add to project.json.$media */
23
+ breakpoints: Record<string, string>;
24
+ /** Per-breakpoint style deltas keyed by breakpoint name */
25
+ deltas: Record<string, DiffedStyle[]>;
26
+ }
27
+
28
+ /**
29
+ * Parse a media query string and extract a pixel width if it's a simple min-width or max-width
30
+ * query. Returns null for complex/non-size queries.
31
+ */
32
+ function parseWidthQuery(query: string): { type: "min" | "max"; width: number } | null {
33
+ const minMatch = query.match(/^\s*\(\s*min-width\s*:\s*([\d.]+)px\s*\)\s*$/);
34
+ if (minMatch) {
35
+ return { type: "min", width: Number(minMatch[1]) };
36
+ }
37
+ const maxMatch = query.match(/^\s*\(\s*max-width\s*:\s*([\d.]+)px\s*\)\s*$/);
38
+ if (maxMatch) {
39
+ return { type: "max", width: Number(maxMatch[1]) };
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** Generate a Jx breakpoint name from a width, e.g. 768 → "--768" */
45
+ function breakpointName(width: number): string {
46
+ return `--${width}`;
47
+ }
48
+
49
+ /**
50
+ * Analyze discovered @media queries and produce a set of testable breakpoints. Only picks up simple
51
+ * min-width / max-width queries — complex queries (orientation, hover, prefers-*) are logged and
52
+ * skipped.
53
+ */
54
+ export function analyzeMediaQueries(queries: string[]): Breakpoint[] {
55
+ const seen = new Set<number>();
56
+ const breakpoints: Breakpoint[] = [];
57
+
58
+ for (const query of queries) {
59
+ const parsed = parseWidthQuery(query);
60
+ if (!parsed) {
61
+ continue;
62
+ }
63
+ if (seen.has(parsed.width)) {
64
+ continue;
65
+ }
66
+ seen.add(parsed.width);
67
+
68
+ // For max-width: test AT that width (it's the upper bound)
69
+ // For min-width: test AT that width (it's the lower bound — styles kick in here)
70
+ breakpoints.push({
71
+ name: breakpointName(parsed.width),
72
+ query,
73
+ testWidth: parsed.width,
74
+ });
75
+ }
76
+
77
+ // Sort by width ascending
78
+ breakpoints.sort((a, b) => a.testWidth - b.testWidth);
79
+ return breakpoints;
80
+ }
81
+
82
+ /**
83
+ * Extract $media style deltas by re-capturing at each breakpoint width.
84
+ *
85
+ * @param page - Puppeteer page (must still have the target page loaded)
86
+ * @param baseElements - Style capture from the base viewport (1440px)
87
+ * @param uaDefaults - UA-default baselines per tagName
88
+ * @param mediaQueries - @media queries discovered in the page's stylesheets
89
+ * @param originalWidth - The base viewport width to restore after extraction
90
+ */
91
+ export async function extractMedia(
92
+ page: Page,
93
+ baseElements: CapturedStyle[],
94
+ uaDefaults: Record<string, Record<string, string>>,
95
+ mediaQueries: string[],
96
+ originalWidth = 1440,
97
+ ): Promise<MediaExtractionResult> {
98
+ const breakpoints = analyzeMediaQueries(mediaQueries);
99
+
100
+ if (breakpoints.length === 0) {
101
+ return { breakpoints: {}, deltas: {} };
102
+ }
103
+
104
+ const projectBreakpoints: Record<string, string> = {};
105
+ const allDeltas: Record<string, DiffedStyle[]> = {};
106
+
107
+ for (const bp of breakpoints) {
108
+ const bpElements = await captureStylesAtWidth(page, bp.testWidth);
109
+ const deltas = computeMediaDelta(baseElements, bpElements, uaDefaults);
110
+
111
+ if (deltas.length > 0) {
112
+ projectBreakpoints[bp.name] = bp.query;
113
+ allDeltas[bp.name] = deltas;
114
+ }
115
+ }
116
+
117
+ // Restore original viewport
118
+ await page.setViewport({ width: originalWidth, height: 900 });
119
+
120
+ return { breakpoints: projectBreakpoints, deltas: allDeltas };
121
+ }