@1agh/maude 0.16.0 → 0.17.1
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 +4 -0
- package/cli/commands/design.mjs +108 -2
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +41 -0
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-shell.tsx +32 -4
- package/plugins/design/dev-server/client/app.jsx +18 -1
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +11 -3
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +80 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- package/plugins/design/templates/_shell.html +33 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Phase 6.5 T5 — HTML adapter (standalone bundler).
|
|
2
|
+
//
|
|
3
|
+
// Renders the target through Playwright, emits a self-contained `index.html`
|
|
4
|
+
// with all stylesheets inlined (fonts + remote images still referenced by
|
|
5
|
+
// origin — full asset inlining is a follow-up). Output is always a ZIP per
|
|
6
|
+
// plan T5 ("Always zipped (multi-file)"), even for a single artboard,
|
|
7
|
+
// because the consuming workflow typically wants one bundle per export.
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import JSZip from 'jszip';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ExportContext,
|
|
17
|
+
type ExportOptions,
|
|
18
|
+
type ExportResult,
|
|
19
|
+
canvasShellUrl,
|
|
20
|
+
} from './index.ts';
|
|
21
|
+
import type { Target } from './scope.ts';
|
|
22
|
+
|
|
23
|
+
const HTML_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_html-playwright.mjs');
|
|
24
|
+
|
|
25
|
+
async function captureHtml(
|
|
26
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
27
|
+
ctx: ExportContext,
|
|
28
|
+
outDir: string,
|
|
29
|
+
timeoutSec: number
|
|
30
|
+
): Promise<string[]> {
|
|
31
|
+
const args = [
|
|
32
|
+
HTML_PLAYWRIGHT,
|
|
33
|
+
'--url',
|
|
34
|
+
canvasShellUrl(ctx, target.file),
|
|
35
|
+
'--selector',
|
|
36
|
+
target.cssPath,
|
|
37
|
+
'--timeout',
|
|
38
|
+
String(timeoutSec),
|
|
39
|
+
];
|
|
40
|
+
if (target.multi) {
|
|
41
|
+
args.push('--multi', '1', '--out-dir', outDir);
|
|
42
|
+
} else {
|
|
43
|
+
args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.html`));
|
|
44
|
+
}
|
|
45
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
46
|
+
cwd: path.dirname(HTML_PLAYWRIGHT),
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'pipe',
|
|
49
|
+
});
|
|
50
|
+
const [stdout, stderr] = await Promise.all([
|
|
51
|
+
new Response(proc.stdout).text(),
|
|
52
|
+
new Response(proc.stderr).text(),
|
|
53
|
+
]);
|
|
54
|
+
const code = await proc.exited;
|
|
55
|
+
if (code !== 0) {
|
|
56
|
+
throw new Error(`_html-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
57
|
+
}
|
|
58
|
+
return stdout
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function run(
|
|
65
|
+
targets: Target[],
|
|
66
|
+
options: ExportOptions,
|
|
67
|
+
ctx: ExportContext
|
|
68
|
+
): Promise<ExportResult> {
|
|
69
|
+
if (!targets.length) {
|
|
70
|
+
return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
|
|
71
|
+
}
|
|
72
|
+
const elementTargets = targets.filter(
|
|
73
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
74
|
+
);
|
|
75
|
+
if (!elementTargets.length) {
|
|
76
|
+
throw new Error('html adapter requires element targets (got file-tree)');
|
|
77
|
+
}
|
|
78
|
+
const timeoutSec = (options.timeoutSec as number | undefined) ?? 8;
|
|
79
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-html-'));
|
|
80
|
+
try {
|
|
81
|
+
const written: string[] = [];
|
|
82
|
+
for (const t of elementTargets) {
|
|
83
|
+
const paths = await captureHtml(t, ctx, tmp, timeoutSec);
|
|
84
|
+
written.push(...paths);
|
|
85
|
+
}
|
|
86
|
+
if (!written.length) {
|
|
87
|
+
return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
|
|
88
|
+
}
|
|
89
|
+
const zip = new JSZip();
|
|
90
|
+
for (const p of written) {
|
|
91
|
+
zip.file(path.basename(p), new Uint8Array(readFileSync(p)));
|
|
92
|
+
}
|
|
93
|
+
const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
94
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
95
|
+
return {
|
|
96
|
+
filename: `${baseSlug}.html.zip`,
|
|
97
|
+
contentType: 'application/zip',
|
|
98
|
+
body: zipBytes,
|
|
99
|
+
};
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Phase 6.5 T1 — adapter registry + dispatch.
|
|
2
|
+
//
|
|
3
|
+
// Each format adapter exports a single `run(targets, options, ctx) → Result`
|
|
4
|
+
// function. This module owns the (format → adapter) lookup and the shared
|
|
5
|
+
// `Result` envelope. Stub adapters live in `./{png,pdf,svg,html,pptx,canva,zip}.ts`
|
|
6
|
+
// and currently return zero-byte payloads with valid MIME types — T2…T7
|
|
7
|
+
// replace them with real renderers.
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import * as canva from './canva.ts';
|
|
12
|
+
import * as html from './html.ts';
|
|
13
|
+
import * as pdf from './pdf.ts';
|
|
14
|
+
import * as png from './png.ts';
|
|
15
|
+
import * as pptx from './pptx.ts';
|
|
16
|
+
import { type ResolveScopeArgs, type Scope, type Target, resolveScope } from './scope.ts';
|
|
17
|
+
import * as svg from './svg.ts';
|
|
18
|
+
import * as zip from './zip.ts';
|
|
19
|
+
|
|
20
|
+
export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
|
|
21
|
+
|
|
22
|
+
export const FORMATS: readonly Format[] = ['png', 'pdf', 'svg', 'html', 'pptx', 'canva', 'zip'];
|
|
23
|
+
|
|
24
|
+
/** Options bag forwarded to the adapter. Format-specific keys are validated by each adapter. */
|
|
25
|
+
export type ExportOptions = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
export interface ExportContext {
|
|
28
|
+
/** Absolute design root (e.g. /abs/.design). */
|
|
29
|
+
designRoot: string;
|
|
30
|
+
/** Absolute repo root. */
|
|
31
|
+
repoRoot: string;
|
|
32
|
+
/** Dev-server origin including port (`http://localhost:NNNN`) — adapters that render via Playwright resolve canvas URLs against this. */
|
|
33
|
+
serverOrigin: string;
|
|
34
|
+
/**
|
|
35
|
+
* designRoot-relative path to the tokens CSS file (e.g. `system/colors_and_type.css`).
|
|
36
|
+
* Threaded into the `?tokens=` query param on the canvas-shell URL so the
|
|
37
|
+
* standalone shell actually loads the project's design tokens — without it
|
|
38
|
+
* `var(--bg-0)` resolves to nothing and renders blank.
|
|
39
|
+
*/
|
|
40
|
+
tokensCssRel?: string;
|
|
41
|
+
/** designRoot-relative path to a shared components CSS file (optional). */
|
|
42
|
+
componentsCssRel?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ExportResult {
|
|
46
|
+
/** Final filename (no path). E.g. `mockup.pdf` or `mockup.zip`. */
|
|
47
|
+
filename: string;
|
|
48
|
+
/** MIME type for the Content-Disposition / Content-Type header. */
|
|
49
|
+
contentType: string;
|
|
50
|
+
/** Payload bytes. T1 stubs return zero-byte placeholders. */
|
|
51
|
+
body: Uint8Array;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Adapter {
|
|
55
|
+
run(targets: Target[], options: ExportOptions, ctx: ExportContext): Promise<ExportResult>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const REGISTRY: Record<Format, Adapter> = {
|
|
59
|
+
png,
|
|
60
|
+
pdf,
|
|
61
|
+
svg,
|
|
62
|
+
html,
|
|
63
|
+
pptx,
|
|
64
|
+
canva,
|
|
65
|
+
zip,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function getAdapter(format: Format): Adapter | null {
|
|
69
|
+
return REGISTRY[format] ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isFormat(s: unknown): s is Format {
|
|
73
|
+
return typeof s === 'string' && (FORMATS as readonly string[]).includes(s);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isScope(s: unknown): s is Scope {
|
|
77
|
+
return s === 'selection' || s === 'artboard' || s === 'canvas-as-separate' || s === 'project-raw';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One-shot dispatch — resolve scope, look up adapter, run it. The HTTP layer
|
|
82
|
+
* is a thin shell over this so CLI/slash can call the same path without
|
|
83
|
+
* re-implementing the orchestration.
|
|
84
|
+
*/
|
|
85
|
+
export async function runExport(args: {
|
|
86
|
+
format: Format;
|
|
87
|
+
scope: Scope;
|
|
88
|
+
options: ExportOptions;
|
|
89
|
+
resolve: Omit<ResolveScopeArgs, 'scope'>;
|
|
90
|
+
ctx: ExportContext;
|
|
91
|
+
}): Promise<ExportResult> {
|
|
92
|
+
const targets = await resolveScope({ ...args.resolve, scope: args.scope });
|
|
93
|
+
const adapter = getAdapter(args.format);
|
|
94
|
+
if (!adapter) {
|
|
95
|
+
throw new Error(`unknown format: ${args.format}`);
|
|
96
|
+
}
|
|
97
|
+
return adapter.run(targets, args.options, args.ctx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { resolveScope };
|
|
101
|
+
export type { Scope, Target };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the `_canvas-shell.html?canvas=…&tokens=…&components=…` URL the
|
|
105
|
+
* playwright shims navigate to. Three normalisations the shell expects:
|
|
106
|
+
*
|
|
107
|
+
* 1. `canvas=` is a **designRoot-relative** path (`ui/Foo.tsx`), NOT a
|
|
108
|
+
* repo-relative one (`.design/ui/Foo.tsx`). Without the strip the shell
|
|
109
|
+
* builds `/.design/.design/ui/Foo.tsx` and 404s.
|
|
110
|
+
* 2. `tokens=` is the designRoot-relative path to the project's tokens CSS.
|
|
111
|
+
* The shell only loads `<link id="canvas-tokens">` when this is set —
|
|
112
|
+
* missing it means every `var(--bg-0)` etc. resolves to undefined and
|
|
113
|
+
* the rendered DOM looks blank in screenshots.
|
|
114
|
+
* 3. `components=` (optional) — designRoot-relative path to a shared
|
|
115
|
+
* components CSS file. Same shell behaviour as tokens.
|
|
116
|
+
*
|
|
117
|
+
* `_active.json.active` leaks the prefixed form straight through; this helper
|
|
118
|
+
* is the single normalization point. `ctx.tokensCssRel` is supplied by the
|
|
119
|
+
* HTTP handler from `cfg.tokensCssRel`.
|
|
120
|
+
*/
|
|
121
|
+
export function canvasShellUrl(ctx: ExportContext, file: string): string {
|
|
122
|
+
const designRel = path.basename(ctx.designRoot);
|
|
123
|
+
const stripPrefix = `${designRel}/`;
|
|
124
|
+
const rel = file.startsWith(stripPrefix) ? file.slice(stripPrefix.length) : file;
|
|
125
|
+
const params = new URLSearchParams();
|
|
126
|
+
params.set('canvas', rel);
|
|
127
|
+
if (ctx.tokensCssRel) params.set('tokens', ctx.tokensCssRel);
|
|
128
|
+
if (ctx.componentsCssRel) params.set('components', ctx.componentsCssRel);
|
|
129
|
+
// Suppress dev-server overlays (tool palette, mini-map, halos, pins, snap
|
|
130
|
+
// guides, annotation chrome) so the export captures only the artboard
|
|
131
|
+
// content. The shell's tiny <style id="canvas-hide-chrome"> rule flips
|
|
132
|
+
// active when this is set.
|
|
133
|
+
params.set('hide-chrome', '1');
|
|
134
|
+
return `${ctx.serverOrigin}/_canvas-shell.html?${params.toString()}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Phase 6.5 T3 — PDF adapter (Playwright page.pdf, vector-faithful).
|
|
2
|
+
//
|
|
3
|
+
// Uses Chromium's print-to-PDF pipeline so the output is a TRUE vector PDF —
|
|
4
|
+
// selectable text, web fonts embedded, SVG primitives kept as paths. The old
|
|
5
|
+
// pdf-lib-over-PNG approach (which the user correctly complained was raster)
|
|
6
|
+
// is gone; this adapter is now ~50 lines because Chromium does the work.
|
|
7
|
+
//
|
|
8
|
+
// Multi-target rendering: one PDF per target via the playwright shim, then
|
|
9
|
+
// pdf-lib concatenates pages into a single document.
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { PDFDocument } from 'pdf-lib';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type ExportContext,
|
|
19
|
+
type ExportOptions,
|
|
20
|
+
type ExportResult,
|
|
21
|
+
canvasShellUrl,
|
|
22
|
+
} from './index.ts';
|
|
23
|
+
import type { Target } from './scope.ts';
|
|
24
|
+
|
|
25
|
+
const PDF_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_pdf-playwright.mjs');
|
|
26
|
+
|
|
27
|
+
async function capturePdf(
|
|
28
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
29
|
+
ctx: ExportContext,
|
|
30
|
+
outDir: string,
|
|
31
|
+
timeoutSec: number
|
|
32
|
+
): Promise<string[]> {
|
|
33
|
+
const args = [
|
|
34
|
+
PDF_PLAYWRIGHT,
|
|
35
|
+
'--url',
|
|
36
|
+
canvasShellUrl(ctx, target.file),
|
|
37
|
+
'--selector',
|
|
38
|
+
target.cssPath,
|
|
39
|
+
'--timeout',
|
|
40
|
+
String(timeoutSec),
|
|
41
|
+
];
|
|
42
|
+
if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
|
|
43
|
+
else args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
|
|
44
|
+
|
|
45
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
46
|
+
cwd: path.dirname(PDF_PLAYWRIGHT),
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'pipe',
|
|
49
|
+
});
|
|
50
|
+
const [stdout, stderr] = await Promise.all([
|
|
51
|
+
new Response(proc.stdout).text(),
|
|
52
|
+
new Response(proc.stderr).text(),
|
|
53
|
+
]);
|
|
54
|
+
const code = await proc.exited;
|
|
55
|
+
if (code !== 0) {
|
|
56
|
+
throw new Error(`_pdf-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
57
|
+
}
|
|
58
|
+
return stdout
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function run(
|
|
65
|
+
targets: Target[],
|
|
66
|
+
options: ExportOptions,
|
|
67
|
+
ctx: ExportContext
|
|
68
|
+
): Promise<ExportResult> {
|
|
69
|
+
if (!targets.length) {
|
|
70
|
+
return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
|
|
71
|
+
}
|
|
72
|
+
const elementTargets = targets.filter(
|
|
73
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
74
|
+
);
|
|
75
|
+
if (!elementTargets.length) {
|
|
76
|
+
throw new Error('pdf adapter requires element targets (got file-tree)');
|
|
77
|
+
}
|
|
78
|
+
const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
|
|
79
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-'));
|
|
80
|
+
try {
|
|
81
|
+
const written: string[] = [];
|
|
82
|
+
for (const t of elementTargets) {
|
|
83
|
+
const paths = await capturePdf(t, ctx, tmp, timeoutSec);
|
|
84
|
+
written.push(...paths);
|
|
85
|
+
}
|
|
86
|
+
if (!written.length) {
|
|
87
|
+
return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
|
|
88
|
+
}
|
|
89
|
+
if (written.length === 1) {
|
|
90
|
+
const bytes = new Uint8Array(readFileSync(written[0]));
|
|
91
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
92
|
+
return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
|
|
93
|
+
}
|
|
94
|
+
// Multi-target → concatenate via pdf-lib `copyPages`. The vector
|
|
95
|
+
// primitives produced by Chromium copy through losslessly — we never
|
|
96
|
+
// re-render or rasterize.
|
|
97
|
+
const out = await PDFDocument.create();
|
|
98
|
+
for (const p of written) {
|
|
99
|
+
const src = await PDFDocument.load(new Uint8Array(readFileSync(p)));
|
|
100
|
+
const pages = await out.copyPages(src, src.getPageIndices());
|
|
101
|
+
for (const page of pages) out.addPage(page);
|
|
102
|
+
}
|
|
103
|
+
const bytes = await out.save();
|
|
104
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
105
|
+
return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Phase 6.5 T2 — PNG adapter (playwright-native).
|
|
2
|
+
//
|
|
3
|
+
// Drives `bin/_png-playwright.mjs` — Chromium-via-Playwright with explicit
|
|
4
|
+
// viewport sizing per target. The previous `screenshot.sh` path used
|
|
5
|
+
// agent-browser which applied its own viewport defaults and produced clipped
|
|
6
|
+
// captures of the world-plane background instead of the artboard. With our
|
|
7
|
+
// own shim we control the viewport, scroll the artboard to (0,0), then
|
|
8
|
+
// `page.screenshot({ clip })`.
|
|
9
|
+
|
|
10
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import JSZip from 'jszip';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ExportContext,
|
|
18
|
+
type ExportOptions,
|
|
19
|
+
type ExportResult,
|
|
20
|
+
canvasShellUrl,
|
|
21
|
+
} from './index.ts';
|
|
22
|
+
import type { Target } from './scope.ts';
|
|
23
|
+
|
|
24
|
+
const PNG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_png-playwright.mjs');
|
|
25
|
+
|
|
26
|
+
interface CaptureOptions {
|
|
27
|
+
scale?: 1 | 2 | 3;
|
|
28
|
+
timeoutSec?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function captureElement(
|
|
32
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
33
|
+
ctx: ExportContext,
|
|
34
|
+
outDir: string,
|
|
35
|
+
options: CaptureOptions
|
|
36
|
+
): Promise<string[]> {
|
|
37
|
+
const args = [
|
|
38
|
+
PNG_PLAYWRIGHT,
|
|
39
|
+
'--url',
|
|
40
|
+
canvasShellUrl(ctx, target.file),
|
|
41
|
+
'--selector',
|
|
42
|
+
target.cssPath,
|
|
43
|
+
'--scale',
|
|
44
|
+
String(options.scale ?? 1),
|
|
45
|
+
'--timeout',
|
|
46
|
+
String(options.timeoutSec ?? 12),
|
|
47
|
+
];
|
|
48
|
+
if (target.multi) {
|
|
49
|
+
args.push('--multi', '1', '--out-dir', outDir);
|
|
50
|
+
} else {
|
|
51
|
+
args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.png`));
|
|
52
|
+
}
|
|
53
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
54
|
+
cwd: path.dirname(PNG_PLAYWRIGHT),
|
|
55
|
+
stdout: 'pipe',
|
|
56
|
+
stderr: 'pipe',
|
|
57
|
+
});
|
|
58
|
+
const [stdout, stderr] = await Promise.all([
|
|
59
|
+
new Response(proc.stdout).text(),
|
|
60
|
+
new Response(proc.stderr).text(),
|
|
61
|
+
]);
|
|
62
|
+
const code = await proc.exited;
|
|
63
|
+
if (code !== 0) {
|
|
64
|
+
throw new Error(`_png-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
65
|
+
}
|
|
66
|
+
return stdout
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readBytes(paths: string[]): Array<{ name: string; bytes: Uint8Array }> {
|
|
73
|
+
return paths.map((p) => ({
|
|
74
|
+
name: path.basename(p),
|
|
75
|
+
bytes: new Uint8Array(readFileSync(p)),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function bundleZip(entries: Array<{ name: string; bytes: Uint8Array }>): Promise<Uint8Array> {
|
|
80
|
+
const zip = new JSZip();
|
|
81
|
+
for (const e of entries) {
|
|
82
|
+
zip.file(e.name, e.bytes);
|
|
83
|
+
}
|
|
84
|
+
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function run(
|
|
88
|
+
targets: Target[],
|
|
89
|
+
options: ExportOptions,
|
|
90
|
+
ctx: ExportContext
|
|
91
|
+
): Promise<ExportResult> {
|
|
92
|
+
if (!targets.length) {
|
|
93
|
+
return { filename: 'export.png', contentType: 'image/png', body: new Uint8Array(0) };
|
|
94
|
+
}
|
|
95
|
+
const elementTargets = targets.filter(
|
|
96
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
97
|
+
);
|
|
98
|
+
if (!elementTargets.length) {
|
|
99
|
+
throw new Error('png adapter requires element targets (got file-tree)');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-png-'));
|
|
103
|
+
const captureOpts: CaptureOptions = {
|
|
104
|
+
scale: (options.scale as 1 | 2 | 3 | undefined) ?? 1,
|
|
105
|
+
timeoutSec: (options.timeoutSec as number | undefined) ?? 8,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const written: string[] = [];
|
|
110
|
+
for (const t of elementTargets) {
|
|
111
|
+
const paths = await captureElement(t, ctx, tmp, captureOpts);
|
|
112
|
+
written.push(...paths);
|
|
113
|
+
}
|
|
114
|
+
const entries = readBytes(written);
|
|
115
|
+
|
|
116
|
+
if (entries.length === 0) {
|
|
117
|
+
return { filename: 'export.png', contentType: 'image/png', body: new Uint8Array(0) };
|
|
118
|
+
}
|
|
119
|
+
if (entries.length === 1) {
|
|
120
|
+
return {
|
|
121
|
+
filename: entries[0].name,
|
|
122
|
+
contentType: 'image/png',
|
|
123
|
+
body: entries[0].bytes,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const zipBytes = await bundleZip(entries);
|
|
127
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
128
|
+
return {
|
|
129
|
+
filename: `${baseSlug}.zip`,
|
|
130
|
+
contentType: 'application/zip',
|
|
131
|
+
body: zipBytes,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
}
|