@1agh/maude 0.16.0 → 0.17.2
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/cli-wrapper.cjs +0 -0
- package/cli/commands/design.mjs +264 -16
- 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/build.ts +118 -6
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-pipeline.ts +5 -0
- 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/compile-entry.test.ts +134 -0
- 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,263 @@
|
|
|
1
|
+
// Phase 6.5 T6b — PPTX adapter via `dom-to-pptx`.
|
|
2
|
+
//
|
|
3
|
+
// The hand-rolled walker + classifier from the previous iteration produced
|
|
4
|
+
// PPTX where text/shape coordinates were collapsed near origin and colours
|
|
5
|
+
// were lost — see DDR-043 for the swap rationale. `dom-to-pptx` runs inside
|
|
6
|
+
// the page, reads computed styles + getBoundingClientRect for each element,
|
|
7
|
+
// and emits one shape per node — the layout the browser already computed.
|
|
8
|
+
// Multi-artboard exports concatenate at the byte level via a re-walk per
|
|
9
|
+
// artboard.
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import path_dirname from 'node:path';
|
|
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 PPTX_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_pptx-playwright.mjs');
|
|
26
|
+
const ENUMERATE_PLAYWRIGHT = path.join(
|
|
27
|
+
import.meta.dir,
|
|
28
|
+
'..',
|
|
29
|
+
'bin',
|
|
30
|
+
'_enumerate-artboards-playwright.mjs'
|
|
31
|
+
);
|
|
32
|
+
// dom-to-pptx ships a pre-bundled UMD that exposes `window.domToPptx`. The
|
|
33
|
+
// exports map hides the bundle path (only `.` is listed), so we resolve via
|
|
34
|
+
// the package.json directory instead of `require.resolve('dom-to-pptx/dist/…')`.
|
|
35
|
+
function pptxBundlePath(): string {
|
|
36
|
+
const pkgJson = require.resolve('dom-to-pptx/package.json');
|
|
37
|
+
return path.join(path.dirname(pkgJson), 'dist', 'dom-to-pptx.bundle.js');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function captureOne(
|
|
41
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
42
|
+
ctx: ExportContext,
|
|
43
|
+
outFile: string,
|
|
44
|
+
timeoutSec: number,
|
|
45
|
+
bundlePath: string,
|
|
46
|
+
selector?: string
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const args = [
|
|
49
|
+
PPTX_PLAYWRIGHT,
|
|
50
|
+
'--url',
|
|
51
|
+
canvasShellUrl(ctx, target.file),
|
|
52
|
+
'--selector',
|
|
53
|
+
selector ?? target.cssPath,
|
|
54
|
+
'--out',
|
|
55
|
+
outFile,
|
|
56
|
+
'--bundle-path',
|
|
57
|
+
bundlePath,
|
|
58
|
+
'--timeout',
|
|
59
|
+
String(timeoutSec),
|
|
60
|
+
];
|
|
61
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
62
|
+
cwd: path.dirname(PPTX_PLAYWRIGHT),
|
|
63
|
+
stdout: 'pipe',
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
const [stdout, stderr] = await Promise.all([
|
|
67
|
+
new Response(proc.stdout).text(),
|
|
68
|
+
new Response(proc.stderr).text(),
|
|
69
|
+
]);
|
|
70
|
+
const code = await proc.exited;
|
|
71
|
+
if (code !== 0) {
|
|
72
|
+
throw new Error(`_pptx-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function run(
|
|
77
|
+
targets: Target[],
|
|
78
|
+
options: ExportOptions,
|
|
79
|
+
ctx: ExportContext
|
|
80
|
+
): Promise<ExportResult> {
|
|
81
|
+
if (!targets.length) {
|
|
82
|
+
return {
|
|
83
|
+
filename: 'export.pptx',
|
|
84
|
+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
85
|
+
body: new Uint8Array(0),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const elementTargets = targets.filter(
|
|
89
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
90
|
+
);
|
|
91
|
+
if (!elementTargets.length) {
|
|
92
|
+
throw new Error('pptx adapter requires element targets (got file-tree)');
|
|
93
|
+
}
|
|
94
|
+
const timeoutSec = (options.timeoutSec as number | undefined) ?? 20;
|
|
95
|
+
const bundlePath = pptxBundlePath();
|
|
96
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pptx-'));
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// Resolve the artboard set we need to render. For `multi: true` we walk
|
|
100
|
+
// `[data-dc-screen]` and render each separately, then merge.
|
|
101
|
+
const target = elementTargets[0];
|
|
102
|
+
if (!target) throw new Error('pptx adapter: no element target');
|
|
103
|
+
const baseSlug = target.canvasSlug ?? 'export';
|
|
104
|
+
|
|
105
|
+
// For canvas-as-separate (`multi: true`), render each artboard as its
|
|
106
|
+
// own PPTX, then concatenate the slides into a single deck.
|
|
107
|
+
if (target.multi) {
|
|
108
|
+
const artboardIds = await enumerateArtboards(target, ctx, timeoutSec);
|
|
109
|
+
const perArtboardFiles: string[] = [];
|
|
110
|
+
for (let i = 0; i < artboardIds.length; i += 1) {
|
|
111
|
+
const id = artboardIds[i];
|
|
112
|
+
const outFile = path.join(tmp, `artboard-${i + 1}.pptx`);
|
|
113
|
+
await captureOne(target, ctx, outFile, timeoutSec, bundlePath, `[data-dc-screen="${id}"]`);
|
|
114
|
+
perArtboardFiles.push(outFile);
|
|
115
|
+
}
|
|
116
|
+
const merged = await mergePptx(perArtboardFiles);
|
|
117
|
+
return {
|
|
118
|
+
filename: `${baseSlug}.pptx`,
|
|
119
|
+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
120
|
+
body: merged,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Single artboard.
|
|
125
|
+
const outFile = path.join(tmp, `${baseSlug}.pptx`);
|
|
126
|
+
await captureOne(target, ctx, outFile, timeoutSec, bundlePath);
|
|
127
|
+
const bytes = new Uint8Array(readFileSync(outFile));
|
|
128
|
+
return {
|
|
129
|
+
filename: `${baseSlug}.pptx`,
|
|
130
|
+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
131
|
+
body: bytes,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Suppress unused.
|
|
139
|
+
void path_dirname;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Walk the live canvas page and return ordered `data-dc-screen` IDs.
|
|
143
|
+
* Reuses the existing playwright shim path — cheap because playwright keeps
|
|
144
|
+
* its browser warm across requests, but still one extra navigation per
|
|
145
|
+
* canvas-as-separate export. Worth caching long-term.
|
|
146
|
+
*/
|
|
147
|
+
async function enumerateArtboards(
|
|
148
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
149
|
+
ctx: ExportContext,
|
|
150
|
+
timeoutSec: number
|
|
151
|
+
): Promise<string[]> {
|
|
152
|
+
// Spawn `bin/_enumerate-artboards-playwright.mjs` via subprocess instead of
|
|
153
|
+
// importing playwright directly — keeps chromium-bidi + playwright internals
|
|
154
|
+
// out of the `bun build --compile` graph for the standalone server binary.
|
|
155
|
+
const url = canvasShellUrl(ctx, target.file);
|
|
156
|
+
const proc = Bun.spawn(
|
|
157
|
+
['node', ENUMERATE_PLAYWRIGHT, '--url', url, '--timeout', String(timeoutSec)],
|
|
158
|
+
{ cwd: path.dirname(ENUMERATE_PLAYWRIGHT), stdout: 'pipe', stderr: 'pipe' }
|
|
159
|
+
);
|
|
160
|
+
const [stdout, stderr] = await Promise.all([
|
|
161
|
+
new Response(proc.stdout).text(),
|
|
162
|
+
new Response(proc.stderr).text(),
|
|
163
|
+
]);
|
|
164
|
+
const code = await proc.exited;
|
|
165
|
+
if (code !== 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`_enumerate-artboards-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return stdout
|
|
171
|
+
.split('\n')
|
|
172
|
+
.map((s) => s.trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Concatenate slides from many single-slide PPTX files into one deck.
|
|
178
|
+
* pptxgenjs has no merge API. We unpack each ZIP, rewrite slide refs, and
|
|
179
|
+
* repack — minimal OOXML twiddling because every dom-to-pptx output has the
|
|
180
|
+
* same layout/master refs (the lib emits them deterministically).
|
|
181
|
+
*/
|
|
182
|
+
async function mergePptx(files: string[]): Promise<Uint8Array> {
|
|
183
|
+
if (files.length === 0) return new Uint8Array(0);
|
|
184
|
+
if (files.length === 1) return new Uint8Array(readFileSync(files[0]));
|
|
185
|
+
const JSZip = (await import('jszip')).default;
|
|
186
|
+
// Use the first file as the base — keep its layout/master/theme/presentation
|
|
187
|
+
// skeleton, replace its slide collection with the union from all inputs.
|
|
188
|
+
const base = await JSZip.loadAsync(new Uint8Array(readFileSync(files[0])));
|
|
189
|
+
const slides: Array<{ name: string; xml: string; rels: string }> = [];
|
|
190
|
+
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
const zip = await JSZip.loadAsync(new Uint8Array(readFileSync(file)));
|
|
193
|
+
const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
|
|
194
|
+
for (const sn of slideNames) {
|
|
195
|
+
const slideEntry = zip.file(sn);
|
|
196
|
+
if (!slideEntry) continue;
|
|
197
|
+
const xml = await slideEntry.async('string');
|
|
198
|
+
const relsName = sn.replace('slides/', 'slides/_rels/').replace('.xml', '.xml.rels');
|
|
199
|
+
const relsEntry = zip.file(relsName);
|
|
200
|
+
const rels = relsEntry ? await relsEntry.async('string') : '';
|
|
201
|
+
slides.push({ name: sn, xml, rels });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Rewrite slide filenames so they're contiguous.
|
|
206
|
+
const next = new JSZip();
|
|
207
|
+
for (const name of Object.keys(base.files)) {
|
|
208
|
+
// `Object.keys(base.files)` includes directory entries — `base.file(name)`
|
|
209
|
+
// returns null for those. Bare path-based filter first, then the entry
|
|
210
|
+
// lookup with a defensive null-check.
|
|
211
|
+
if (/\/$/.test(name)) continue;
|
|
212
|
+
if (/^ppt\/slides\/(slide|_rels\/slide)\d+\.xml(\.rels)?$/.test(name)) continue;
|
|
213
|
+
const entry = base.file(name);
|
|
214
|
+
if (!entry || entry.dir) continue;
|
|
215
|
+
next.file(name, await entry.async('uint8array'));
|
|
216
|
+
}
|
|
217
|
+
// Insert the merged slide list.
|
|
218
|
+
for (let i = 0; i < slides.length; i += 1) {
|
|
219
|
+
const s = slides[i];
|
|
220
|
+
const idx = i + 1;
|
|
221
|
+
next.file(`ppt/slides/slide${idx}.xml`, s.xml);
|
|
222
|
+
next.file(`ppt/slides/_rels/slide${idx}.xml.rels`, s.rels);
|
|
223
|
+
}
|
|
224
|
+
// Patch ppt/presentation.xml + its rels to reference the new slide count.
|
|
225
|
+
// The original presentation.xml from the first file references slide1; for
|
|
226
|
+
// merged decks > 1 slide we need to grow the sldIdLst. Done via simple
|
|
227
|
+
// regex on the XML string.
|
|
228
|
+
const presPath = 'ppt/presentation.xml';
|
|
229
|
+
const presRelsPath = 'ppt/_rels/presentation.xml.rels';
|
|
230
|
+
const presEntry = base.file(presPath);
|
|
231
|
+
const presRelsEntry = base.file(presRelsPath);
|
|
232
|
+
if (!presEntry || !presRelsEntry) {
|
|
233
|
+
// Malformed input — fall through to first input untouched. Better than
|
|
234
|
+
// throwing inside the merge for a quirk we can't recover from.
|
|
235
|
+
return new Uint8Array(readFileSync(files[0]));
|
|
236
|
+
}
|
|
237
|
+
const presXml = await presEntry.async('string');
|
|
238
|
+
const presRels = await presRelsEntry.async('string');
|
|
239
|
+
// sldIdLst: rebuild with N entries.
|
|
240
|
+
const idEntries = slides
|
|
241
|
+
.map((_, i) => `<p:sldId id="${256 + i}" r:id="rId${100 + i}"/>`)
|
|
242
|
+
.join('');
|
|
243
|
+
const newPresXml = presXml.replace(
|
|
244
|
+
/<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/,
|
|
245
|
+
`<p:sldIdLst>${idEntries}</p:sldIdLst>`
|
|
246
|
+
);
|
|
247
|
+
next.file(presPath, newPresXml);
|
|
248
|
+
// Build rels with N slide refs, keeping existing non-slide rels.
|
|
249
|
+
const existingRels = presRels.match(/<Relationship[^/]*\/>/g) ?? [];
|
|
250
|
+
const nonSlideRels = existingRels.filter(
|
|
251
|
+
(r) => !/Type="http:\/\/[^"]*relationships\/slide"/.test(r)
|
|
252
|
+
);
|
|
253
|
+
const slideRels = slides
|
|
254
|
+
.map(
|
|
255
|
+
(_, i) =>
|
|
256
|
+
`<Relationship Id="rId${100 + i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`
|
|
257
|
+
)
|
|
258
|
+
.join('');
|
|
259
|
+
const newPresRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${nonSlideRels.join('')}${slideRels}</Relationships>`;
|
|
260
|
+
next.file(presRelsPath, newPresRels);
|
|
261
|
+
|
|
262
|
+
return next.generateAsync({ type: 'uint8array' });
|
|
263
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Phase 6.5 T1 — scope resolver.
|
|
2
|
+
//
|
|
3
|
+
// Pure function: takes the current `_active.json` state + a user-chosen
|
|
4
|
+
// scope, returns a flat `Target[]`. The downstream adapter (PNG / PDF / …)
|
|
5
|
+
// owns rendering each Target; the resolver is render-agnostic.
|
|
6
|
+
//
|
|
7
|
+
// Why a single function instead of one-per-scope: the four scopes share an
|
|
8
|
+
// `activeJson` precondition and a designRoot walk for `project-raw`. Keeping
|
|
9
|
+
// them inline makes the fallback chain (`selection` → `artboard` when no
|
|
10
|
+
// selection captured) explicit.
|
|
11
|
+
|
|
12
|
+
import type { Dirent } from 'node:fs';
|
|
13
|
+
import { readdir } from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** The four user-facing scope choices from the export dialog. */
|
|
17
|
+
export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What an adapter receives for each render unit. `element` targets carry a
|
|
21
|
+
* CSS selector + canvas reference (resolved at render time via Playwright);
|
|
22
|
+
* `file-tree` targets carry a flat list of repo-relative paths (consumed by
|
|
23
|
+
* the zip adapter only — other adapters reject).
|
|
24
|
+
*/
|
|
25
|
+
export type Target =
|
|
26
|
+
| {
|
|
27
|
+
kind: 'element';
|
|
28
|
+
/** CSS selector. Multi-match selectors (e.g. `[data-dc-screen]`) are valid; see `multi`. */
|
|
29
|
+
cssPath: string;
|
|
30
|
+
/** Slug derived from the canvas file path — POSIX, ext-less, relative to designRoot. */
|
|
31
|
+
canvasSlug: string;
|
|
32
|
+
/** Repo-relative canvas file path. */
|
|
33
|
+
file: string;
|
|
34
|
+
/** True when `cssPath` is expected to match many elements; the adapter iterates. */
|
|
35
|
+
multi?: boolean;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
kind: 'file-tree';
|
|
39
|
+
/** Repo-relative file paths to bundle. Always non-empty. */
|
|
40
|
+
paths: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Subset of `_active.json` the resolver consumes. */
|
|
44
|
+
export interface ActiveJsonShape {
|
|
45
|
+
active: string | null;
|
|
46
|
+
selected:
|
|
47
|
+
| { file?: string; selector?: string; cssPath?: string }
|
|
48
|
+
| Array<{ file?: string; selector?: string; cssPath?: string }>
|
|
49
|
+
| null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ResolveScopeArgs {
|
|
53
|
+
scope: Scope;
|
|
54
|
+
activeJson: ActiveJsonShape;
|
|
55
|
+
/** Absolute path to the design root (e.g. `/abs/.design`). */
|
|
56
|
+
designRoot: string;
|
|
57
|
+
/** Absolute path to repo root. Required for `project-raw` to bound the walk. */
|
|
58
|
+
repoRoot?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const RAW_EXCLUDES = new Set([
|
|
62
|
+
'_server.json',
|
|
63
|
+
'_active.json',
|
|
64
|
+
'_export-history.json',
|
|
65
|
+
'_history',
|
|
66
|
+
'_comments',
|
|
67
|
+
'_canvas-state',
|
|
68
|
+
'node_modules',
|
|
69
|
+
'dist',
|
|
70
|
+
'.DS_Store',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derive a canvas slug from a repo-relative or designRoot-relative file path.
|
|
75
|
+
* Mirrors `api.ts:fileSlug` semantics but stays inline to avoid a circular
|
|
76
|
+
* import (api.ts will eventually call into exporters from `commentsAdd`-style
|
|
77
|
+
* factories).
|
|
78
|
+
*/
|
|
79
|
+
function slugify(file: string, designRel: string): string {
|
|
80
|
+
let p = String(file).replace(/^\/+|\/+$/g, '');
|
|
81
|
+
const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
82
|
+
if (p.startsWith(prefix)) p = p.slice(prefix.length);
|
|
83
|
+
return p
|
|
84
|
+
.replace(/\//g, '-')
|
|
85
|
+
.replace(/\s+/g, '_')
|
|
86
|
+
.replace(/\.(tsx|html)$/i, '')
|
|
87
|
+
.replace(/^\.+/, '')
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SelectionShape {
|
|
92
|
+
file?: string;
|
|
93
|
+
selector?: string;
|
|
94
|
+
cssPath?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstSelection(selected: ActiveJsonShape['selected']): SelectionShape | null {
|
|
98
|
+
if (!selected) return null;
|
|
99
|
+
if (Array.isArray(selected)) return selected[0] ?? null;
|
|
100
|
+
return selected;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function walkProjectRaw(root: string): Promise<string[]> {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
async function walk(absDir: string, relDir: string): Promise<void> {
|
|
106
|
+
let entries: Dirent[];
|
|
107
|
+
try {
|
|
108
|
+
entries = await readdir(absDir, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (RAW_EXCLUDES.has(e.name)) continue;
|
|
115
|
+
if (e.name.endsWith('.log')) continue;
|
|
116
|
+
const abs = path.join(absDir, e.name);
|
|
117
|
+
const rel = relDir ? path.posix.join(relDir, e.name) : e.name;
|
|
118
|
+
if (e.isDirectory()) {
|
|
119
|
+
await walk(abs, rel);
|
|
120
|
+
} else if (e.isFile()) {
|
|
121
|
+
out.push(rel);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await walk(root, '');
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a scope choice into a Target[]. Pure beyond filesystem walks for
|
|
131
|
+
* `project-raw`. Never throws — invalid input collapses to `[]` and the
|
|
132
|
+
* adapter's "no targets" path emits an empty payload.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
|
|
135
|
+
const { scope, activeJson, designRoot } = args;
|
|
136
|
+
const designRel = path.basename(designRoot);
|
|
137
|
+
|
|
138
|
+
// `project-raw` is independent of `_active.json` — the user always exports
|
|
139
|
+
// the whole tree regardless of what's selected.
|
|
140
|
+
if (scope === 'project-raw') {
|
|
141
|
+
const paths = await walkProjectRaw(designRoot);
|
|
142
|
+
if (!paths.length) return [];
|
|
143
|
+
return [{ kind: 'file-tree', paths }];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const activeFile = activeJson.active;
|
|
147
|
+
if (!activeFile) return [];
|
|
148
|
+
const slug = slugify(activeFile, designRel);
|
|
149
|
+
const sel = firstSelection(activeJson.selected);
|
|
150
|
+
|
|
151
|
+
if (scope === 'selection') {
|
|
152
|
+
const selector = sel?.selector ?? sel?.cssPath;
|
|
153
|
+
if (!sel || !selector) {
|
|
154
|
+
// Plan: "Falls back to artboard if no selection." Recurse with the
|
|
155
|
+
// artboard scope so the fallback semantics live in one place.
|
|
156
|
+
return resolveScope({ ...args, scope: 'artboard' });
|
|
157
|
+
}
|
|
158
|
+
const file = sel.file ?? activeFile;
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
kind: 'element',
|
|
162
|
+
cssPath: selector,
|
|
163
|
+
canvasSlug: slugify(file, designRel),
|
|
164
|
+
file,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (scope === 'artboard') {
|
|
170
|
+
// The adapter handles "closest [data-dc-screen] ancestor" at render time
|
|
171
|
+
// via Playwright. Server-side we only know the selection's selector — we
|
|
172
|
+
// pass it through with a marker and the adapter widens to the artboard.
|
|
173
|
+
// If no selection, fall back to "first artboard on the active canvas".
|
|
174
|
+
const baseSelector = sel?.selector ?? sel?.cssPath ?? '[data-dc-screen]:first-of-type';
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
kind: 'element',
|
|
178
|
+
cssPath: baseSelector,
|
|
179
|
+
canvasSlug: slug,
|
|
180
|
+
file: activeFile,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// canvas-as-separate — every [data-dc-screen] on the active canvas.
|
|
186
|
+
// Adapter expands `multi: true` into N renders in document order.
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
kind: 'element',
|
|
190
|
+
cssPath: '[data-dc-screen]',
|
|
191
|
+
canvasSlug: slug,
|
|
192
|
+
file: activeFile,
|
|
193
|
+
multi: true,
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Phase 6.5 T4 — SVG adapter.
|
|
2
|
+
//
|
|
3
|
+
// Walks the rendered DOM via Playwright (mirrors the screenshot path) and
|
|
4
|
+
// emits an SVG with <foreignObject>-wrapped HTML + concatenated stylesheets.
|
|
5
|
+
// Web fonts are NOT inlined in v1 — see plan T4 caveat + DDR (Safari +
|
|
6
|
+
// Illustrator render foreignObject inconsistently; vector text from the
|
|
7
|
+
// HTML payload remains editable in Illustrator/Inkscape on the happy path).
|
|
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 { getBrowserBundle } from './_browser-bundles.ts';
|
|
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 SVG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_svg-playwright.mjs');
|
|
25
|
+
|
|
26
|
+
async function captureSvg(
|
|
27
|
+
target: Extract<Target, { kind: 'element' }>,
|
|
28
|
+
ctx: ExportContext,
|
|
29
|
+
outDir: string,
|
|
30
|
+
timeoutSec: number,
|
|
31
|
+
bundlePath: string
|
|
32
|
+
): Promise<string[]> {
|
|
33
|
+
const args = [
|
|
34
|
+
SVG_PLAYWRIGHT,
|
|
35
|
+
'--url',
|
|
36
|
+
canvasShellUrl(ctx, target.file),
|
|
37
|
+
'--selector',
|
|
38
|
+
target.cssPath,
|
|
39
|
+
'--bundle-path',
|
|
40
|
+
bundlePath,
|
|
41
|
+
'--timeout',
|
|
42
|
+
String(timeoutSec),
|
|
43
|
+
];
|
|
44
|
+
if (target.multi) {
|
|
45
|
+
args.push('--multi', '1', '--out-dir', outDir);
|
|
46
|
+
} else {
|
|
47
|
+
args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.svg`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Run via `node` so the shim's `import 'playwright'` resolves against
|
|
51
|
+
// dev-server/node_modules (playwright is a devDep). `npm exec` doesn't
|
|
52
|
+
// bridge the module path for ESM imports — confirmed against npm 10.x.
|
|
53
|
+
const proc = Bun.spawn(['node', ...args], {
|
|
54
|
+
cwd: path.dirname(SVG_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(`_svg-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
|
+
export async function run(
|
|
73
|
+
targets: Target[],
|
|
74
|
+
options: ExportOptions,
|
|
75
|
+
ctx: ExportContext
|
|
76
|
+
): Promise<ExportResult> {
|
|
77
|
+
if (!targets.length) {
|
|
78
|
+
return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
|
|
79
|
+
}
|
|
80
|
+
const elementTargets = targets.filter(
|
|
81
|
+
(t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
|
|
82
|
+
);
|
|
83
|
+
if (!elementTargets.length) {
|
|
84
|
+
throw new Error('svg adapter requires element targets (got file-tree)');
|
|
85
|
+
}
|
|
86
|
+
const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
|
|
87
|
+
const bundlePath = await getBrowserBundle('dom-to-svg', 'domToSvg');
|
|
88
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-svg-'));
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const written: string[] = [];
|
|
92
|
+
for (const t of elementTargets) {
|
|
93
|
+
const paths = await captureSvg(t, ctx, tmp, timeoutSec, bundlePath);
|
|
94
|
+
written.push(...paths);
|
|
95
|
+
}
|
|
96
|
+
if (!written.length) {
|
|
97
|
+
return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
|
|
98
|
+
}
|
|
99
|
+
const entries = written.map((p) => ({
|
|
100
|
+
name: path.basename(p),
|
|
101
|
+
bytes: new Uint8Array(readFileSync(p)),
|
|
102
|
+
}));
|
|
103
|
+
if (entries.length === 1) {
|
|
104
|
+
return {
|
|
105
|
+
filename: entries[0].name,
|
|
106
|
+
contentType: 'image/svg+xml',
|
|
107
|
+
body: entries[0].bytes,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const zip = new JSZip();
|
|
111
|
+
for (const e of entries) zip.file(e.name, e.bytes);
|
|
112
|
+
const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
113
|
+
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
|
|
114
|
+
return {
|
|
115
|
+
filename: `${baseSlug}.svg.zip`,
|
|
116
|
+
contentType: 'application/zip',
|
|
117
|
+
body: zipBytes,
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Phase 6.5 T7 — project-raw ZIP adapter.
|
|
2
|
+
//
|
|
3
|
+
// Consumes one or more file-tree targets (typically just the single one the
|
|
4
|
+
// scope resolver returns for `project-raw`) and streams them into a ZIP.
|
|
5
|
+
// Excludes runtime + node_modules + dist by default; user can override via
|
|
6
|
+
// `options.exclude` (gitignore-style globs) or `options.include` (filter to
|
|
7
|
+
// `'system' | 'canvases' | 'assets' | 'meta'` subtrees).
|
|
8
|
+
//
|
|
9
|
+
// Memory profile: JSZip's `generateAsync({ type: 'uint8array' })` builds the
|
|
10
|
+
// archive in memory. Plan T7 calls for streaming via Bun's Response stream
|
|
11
|
+
// to bound RSS at large designRoots — that's a refinement once a real user
|
|
12
|
+
// hits the buffer ceiling. Until then, the buffered path keeps the contract
|
|
13
|
+
// simple (Uint8Array body matches every other adapter).
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import JSZip from 'jszip';
|
|
19
|
+
|
|
20
|
+
import type { ExportContext, ExportOptions, ExportResult } from './index.ts';
|
|
21
|
+
import type { Target } from './scope.ts';
|
|
22
|
+
|
|
23
|
+
type IncludeTag = 'system' | 'canvases' | 'assets' | 'meta';
|
|
24
|
+
|
|
25
|
+
function matchesGlob(p: string, glob: string): boolean {
|
|
26
|
+
// Tiny gitignore-style matcher. Supports `*`, `**`, and a trailing slash to
|
|
27
|
+
// mean "directory". Anything fancier (negation, brace expansion) is left to
|
|
28
|
+
// the user — surface in T13 docs.
|
|
29
|
+
const norm = glob.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
30
|
+
if (norm.includes('**')) {
|
|
31
|
+
const re = new RegExp(
|
|
32
|
+
`^${norm
|
|
33
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
34
|
+
.replace(/\*\*/g, '.*')
|
|
35
|
+
.replace(/\*/g, '[^/]*')}(?:/.*)?$`
|
|
36
|
+
);
|
|
37
|
+
return re.test(p);
|
|
38
|
+
}
|
|
39
|
+
if (norm.includes('*')) {
|
|
40
|
+
const re = new RegExp(`^${norm.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`);
|
|
41
|
+
return re.test(p);
|
|
42
|
+
}
|
|
43
|
+
return p === norm || p.startsWith(`${norm}/`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUnder(p: string, prefix: string): boolean {
|
|
47
|
+
return p === prefix || p.startsWith(`${prefix}/`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function filterPaths(paths: string[], options: ExportOptions): string[] {
|
|
51
|
+
const exclude = Array.isArray(options.exclude) ? (options.exclude as string[]) : [];
|
|
52
|
+
const include = Array.isArray(options.include) ? (options.include as IncludeTag[]) : null;
|
|
53
|
+
const tagPrefix: Record<IncludeTag, string> = {
|
|
54
|
+
system: 'system',
|
|
55
|
+
canvases: 'ui',
|
|
56
|
+
assets: 'assets',
|
|
57
|
+
meta: '',
|
|
58
|
+
};
|
|
59
|
+
return paths.filter((p) => {
|
|
60
|
+
if (exclude.some((g) => matchesGlob(p, g))) return false;
|
|
61
|
+
if (include) {
|
|
62
|
+
const hit = include.some((tag) => {
|
|
63
|
+
if (tag === 'meta') {
|
|
64
|
+
return /\.(json|md|css)$/i.test(p) && !p.includes('/');
|
|
65
|
+
}
|
|
66
|
+
return isUnder(p, tagPrefix[tag]);
|
|
67
|
+
});
|
|
68
|
+
if (!hit) return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function run(
|
|
75
|
+
targets: Target[],
|
|
76
|
+
options: ExportOptions,
|
|
77
|
+
ctx: ExportContext
|
|
78
|
+
): Promise<ExportResult> {
|
|
79
|
+
if (!targets.length) {
|
|
80
|
+
return { filename: 'project.zip', contentType: 'application/zip', body: new Uint8Array(0) };
|
|
81
|
+
}
|
|
82
|
+
const fileTreeTargets = targets.filter(
|
|
83
|
+
(t): t is Extract<Target, { kind: 'file-tree' }> => t.kind === 'file-tree'
|
|
84
|
+
);
|
|
85
|
+
if (!fileTreeTargets.length) {
|
|
86
|
+
throw new Error('zip adapter requires file-tree targets (got element)');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const allPaths = fileTreeTargets.flatMap((t) => t.paths);
|
|
90
|
+
const kept = filterPaths(allPaths, options);
|
|
91
|
+
|
|
92
|
+
const zip = new JSZip();
|
|
93
|
+
for (const rel of kept) {
|
|
94
|
+
const abs = path.join(ctx.designRoot, rel);
|
|
95
|
+
try {
|
|
96
|
+
const bytes = readFileSync(abs);
|
|
97
|
+
zip.file(rel, new Uint8Array(bytes));
|
|
98
|
+
} catch {
|
|
99
|
+
// File vanished between scope walk and read — skip.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
103
|
+
const projectName = path.basename(ctx.designRoot).replace(/^\./, '') || 'project';
|
|
104
|
+
return {
|
|
105
|
+
filename: `${projectName}.zip`,
|
|
106
|
+
contentType: 'application/zip',
|
|
107
|
+
body: bytes,
|
|
108
|
+
};
|
|
109
|
+
}
|