@1agh/maude 0.15.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 -2
- 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 +227 -3
- 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 +111 -9
- package/plugins/design/dev-server/client/app.jsx +71 -143
- package/plugins/design/dev-server/client/comments-overlay.css +381 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
- package/plugins/design/dev-server/client/styles/4-components.css +5 -161
- package/plugins/design/dev-server/client/styles.css +5 -160
- package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +52 -211
- package/plugins/design/dev-server/dist/styles.css +1 -218
- 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 +109 -0
- package/plugins/design/dev-server/input-router.tsx +21 -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/canvas-meta-api.test.ts +0 -10
- package/plugins/design/dev-server/test/comments-api.test.ts +229 -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,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
|
+
}
|
|
@@ -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
|
+
}
|