afterimage-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -9
- package/agents/cursor/afterimage.mdc +36 -18
- package/agents/skill/SKILL.md +36 -18
- package/package.json +65 -61
- package/src/cli/commands/dev.js +376 -194
- package/src/cli/commands/init.js +119 -8
- package/src/cli/commands/save.js +12 -6
- package/src/cli/hosts.js +24 -7
- package/src/cli/site.js +6 -5
- package/src/runtime/overlay.js +266 -0
- package/viewer-dist/assets/{index-DLk_P7m5.js → index-29Tm-kiu.js} +1 -1
- package/viewer-dist/index.html +1 -1
package/src/cli/commands/init.js
CHANGED
|
@@ -35,7 +35,10 @@ const skillHint = [
|
|
|
35
35
|
...Object.entries(agents).map(([name, agent]) => ` npx afterimage init --agent ${name} copies the ${agent.what} to ${agent.to}${agent.what === 'skill' ? '/' : ''}`)
|
|
36
36
|
].join('\n');
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// The dev script starts Afterimage beside the app when init could set it; otherwise the app's own command goes after --.
|
|
39
|
+
const nextStep = devReady => (devReady
|
|
40
|
+
? 'Next: npm run dev, then use the Afterimage pill on your prototype to save versions and see them all.'
|
|
41
|
+
: 'Next: start your dev server through Afterimage with npx afterimage dev -- <your dev command>, then use the pill on your prototype to save versions and see them all.');
|
|
39
42
|
|
|
40
43
|
async function isFolder(file) {
|
|
41
44
|
try {
|
|
@@ -146,22 +149,39 @@ async function planConfig(cwd, flags, ctx) {
|
|
|
146
149
|
};
|
|
147
150
|
}
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
// Read and checked before anything else, so a bad package.json stops init before any question.
|
|
153
|
+
async function readPackage(cwd) {
|
|
150
154
|
const file = path.join(cwd, 'package.json');
|
|
151
155
|
const { bom, body: before } = await readJsonText(file);
|
|
152
156
|
const manifest = before === null ? { private: true, scripts: {} } : parseJson(before, 'package.json');
|
|
153
157
|
if (manifest.scripts !== undefined && (!manifest.scripts || typeof manifest.scripts !== 'object' || Array.isArray(manifest.scripts))) {
|
|
154
158
|
throw new Error('package.json "scripts" must be an object.\nFix it, then run "afterimage init" again.');
|
|
155
159
|
}
|
|
160
|
+
return { file, bom, before, manifest };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The pill on the prototype needs afterimage dev running beside the app. So the project's own dev script starts it:
|
|
164
|
+
// "vite" becomes "afterimage dev -- vite", which runs both and stops both. A plain folder with no dev script gets
|
|
165
|
+
// "afterimage dev", which serves the folder itself. Run again, it changes nothing.
|
|
166
|
+
const devWrap = 'afterimage dev';
|
|
167
|
+
function devScript(existing, config) {
|
|
168
|
+
if (typeof existing.dev === 'string') return existing.dev.trim().startsWith(devWrap) ? null : `${devWrap} -- ${existing.dev}`;
|
|
169
|
+
return config.build ? null : devWrap;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function planPackage({ file, bom, before, manifest }, config) {
|
|
156
173
|
const existing = manifest.scripts ?? {};
|
|
157
174
|
const added = Object.keys(scripts).filter(name => !Object.hasOwn(existing, name));
|
|
158
175
|
const notes = [];
|
|
159
176
|
if (Object.hasOwn(existing, 'afterimage:build') && existing['afterimage:build'] !== scripts['afterimage:build']) {
|
|
160
177
|
notes.push(`package.json already has "afterimage:build": "${existing['afterimage:build']}". The host config runs that script, so it should be "${scripts['afterimage:build']}".`);
|
|
161
178
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
179
|
+
const dev = devScript(existing, config);
|
|
180
|
+
if (!added.length && !dev) return { change: null, notes, apply: null };
|
|
181
|
+
const wraps = dev && Object.hasOwn(existing, 'dev');
|
|
182
|
+
manifest.scripts = { ...existing, ...Object.fromEntries(added.map(name => [name, scripts[name]])), ...(dev ? { dev } : {}) };
|
|
183
|
+
const count = added.length + (dev && !wraps ? 1 : 0);
|
|
184
|
+
const what = [count ? `added ${plural(count, 'script')}` : null, wraps ? 'dev now starts Afterimage too' : null].filter(Boolean).join('; ');
|
|
165
185
|
return {
|
|
166
186
|
change: `${before === null ? 'Created' : 'Updated'} package.json (${what})`,
|
|
167
187
|
notes,
|
|
@@ -169,6 +189,95 @@ async function planPackage(cwd) {
|
|
|
169
189
|
};
|
|
170
190
|
}
|
|
171
191
|
|
|
192
|
+
// The overlay's one line. A plain page loads it from the history's own folder, which afterimage dev and the deployed
|
|
193
|
+
// site both serve; a Vite page imports it from the package, since Vite's dev server serves no /afterimage/.
|
|
194
|
+
const pillTag = '<script src="/afterimage/overlay.js" defer></script>';
|
|
195
|
+
const pillImport = "<script type=\"module\">import 'afterimage-cli/overlay';</script>";
|
|
196
|
+
const pillNotes = {
|
|
197
|
+
'next-static': "Add the Afterimage pill to your app: create app/afterimage.tsx with\n 'use client';\n import 'afterimage-cli/overlay';\n export default function Afterimage() { return null; }\nand put <Afterimage /> inside <body> in app/layout.tsx.",
|
|
198
|
+
astro: "Add the Afterimage pill to your app: put <script>import 'afterimage-cli/overlay';</script> in the layout every page uses.",
|
|
199
|
+
'sveltekit-static': "Add the Afterimage pill to your app: put import 'afterimage-cli/overlay'; in the <script> of src/routes/+layout.svelte.",
|
|
200
|
+
unknown: "Add the Afterimage pill to your app: import 'afterimage-cli/overlay' once in the code every page loads."
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Before the last </body>, or at the end of the file when it has none, on a line of its own. When </body> starts its
|
|
204
|
+
// line, the new line takes the indent of the line above it, so it sits with the page's other scripts.
|
|
205
|
+
function withLine(text, line) {
|
|
206
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
207
|
+
const at = text.search(/<\/body>(?![\s\S]*<\/body>)/i);
|
|
208
|
+
if (at === -1) return `${text}${text === '' || text.endsWith('\n') ? '' : eol}${line}${eol}`;
|
|
209
|
+
const lineStart = text.lastIndexOf('\n', at - 1) + 1;
|
|
210
|
+
if (!/^[ \t]*$/.test(text.slice(lineStart, at))) return `${text.slice(0, at)}${eol}${line}${eol}${text.slice(at)}`;
|
|
211
|
+
const above = text.slice(0, lineStart).replace(/\r?\n$/, '').split(/\r?\n/).pop();
|
|
212
|
+
return `${text.slice(0, lineStart)}${above.match(/^[ \t]*/)[0]}${line}${eol}${text.slice(lineStart)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Every file under `dir` whose name passes `want`, never going into hidden folders, installed packages or `skip`
|
|
216
|
+
// (absolute folders), which a real project's node_modules makes worth not reading at all.
|
|
217
|
+
async function walk(dir, want, skip) {
|
|
218
|
+
const found = [];
|
|
219
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
220
|
+
const full = path.join(dir, entry.name);
|
|
221
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
if (!skip.includes(full)) found.push(...await walk(full, want, skip));
|
|
224
|
+
} else if (entry.isFile() && want(entry.name)) {
|
|
225
|
+
found.push(full);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Every .html page in the prototype's folder, minus the history and Afterimage's own output.
|
|
232
|
+
async function plainPages(cwd, config) {
|
|
233
|
+
const skip = [config.history, config.out].map(folder => path.resolve(cwd, folder));
|
|
234
|
+
const pages = await walk(path.resolve(cwd, config.source), name => /\.html?$/i.test(name), skip);
|
|
235
|
+
return pages.map(full => ({ full, name: path.relative(cwd, full).replace(/\\/g, '/') })).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Whether the app's own code already loads the overlay, so init stops asking once the line is in. Only source files
|
|
239
|
+
// are read: not installed packages, hidden folders, the history, or what a build wrote.
|
|
240
|
+
const codeFile = /\.(m?[jt]sx?|svelte|astro|vue|html?)$/i;
|
|
241
|
+
async function loadsPill(cwd, config) {
|
|
242
|
+
const skip = [config.history, config.out, config.source].filter(folder => folder !== '.').map(folder => path.resolve(cwd, folder));
|
|
243
|
+
for (const full of await walk(cwd, name => codeFile.test(name), skip)) {
|
|
244
|
+
const text = await readText(full);
|
|
245
|
+
if (text.includes('afterimage-cli/overlay') || text.includes('/afterimage/overlay.js')) return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function planPill(cwd, config) {
|
|
251
|
+
let pages = [];
|
|
252
|
+
let line = pillTag;
|
|
253
|
+
const noteFor = async type => ({ change: null, notes: await loadsPill(cwd, config) ? [] : [pillNotes[type] ?? pillNotes.unknown], apply: null });
|
|
254
|
+
if (config.type === 'plain') {
|
|
255
|
+
pages = await plainPages(cwd, config);
|
|
256
|
+
} else if (config.type === 'vite') {
|
|
257
|
+
const index = path.join(cwd, 'index.html');
|
|
258
|
+
if (await readText(index) === null) return noteFor('unknown');
|
|
259
|
+
pages = [{ full: index, name: 'index.html' }];
|
|
260
|
+
line = pillImport;
|
|
261
|
+
} else {
|
|
262
|
+
return noteFor(config.type);
|
|
263
|
+
}
|
|
264
|
+
const writes = [];
|
|
265
|
+
for (const page of pages) {
|
|
266
|
+
const text = await readText(page.full);
|
|
267
|
+
if (text.includes('/afterimage/overlay.js') || text.includes('afterimage-cli/overlay')) continue;
|
|
268
|
+
writes.push({ ...page, text: withLine(text, line) });
|
|
269
|
+
}
|
|
270
|
+
if (!writes.length) return { change: null, notes: [], apply: null };
|
|
271
|
+
const names = writes.map(page => page.name);
|
|
272
|
+
return {
|
|
273
|
+
change: names.length === 1 ? `Added the Afterimage pill to ${names[0]}` : `Added the Afterimage pill to ${names.length} pages (${names.join(', ')})`,
|
|
274
|
+
notes: [],
|
|
275
|
+
apply: async () => {
|
|
276
|
+
for (const page of writes) await fs.writeFile(page.full, page.text);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
172
281
|
// Adds each line that is not already there, in the file's own line ending.
|
|
173
282
|
async function planLines(cwd, name, lines, isPresent) {
|
|
174
283
|
const file = path.join(cwd, name);
|
|
@@ -274,11 +383,12 @@ export async function run(args, ctx) {
|
|
|
274
383
|
// and detection (which reads package.json) still sees the project as it was.
|
|
275
384
|
let plans;
|
|
276
385
|
try {
|
|
277
|
-
const
|
|
386
|
+
const packageFile = await readPackage(cwd);
|
|
278
387
|
const configPlan = await planConfig(cwd, flags, prompt);
|
|
279
388
|
plans = [
|
|
280
389
|
configPlan,
|
|
281
|
-
|
|
390
|
+
planPackage(packageFile, configPlan.config),
|
|
391
|
+
await planPill(cwd, configPlan.config),
|
|
282
392
|
await planLines(cwd, '.gitattributes', [`${configPlan.config.history}/** -text`], hasTextRule),
|
|
283
393
|
// The save's lock and stage folders too, which a killed save can leave in the history folder.
|
|
284
394
|
await planLines(cwd, '.gitignore', ['.afterimage/', `${configPlan.config.history}/.afterimage-*`, 'node_modules/'], hasIgnore),
|
|
@@ -302,6 +412,7 @@ export async function run(args, ctx) {
|
|
|
302
412
|
if (notes.length) stdout.write(`${changes.length ? '\n' : ''}${notes.join('\n\n')}\n`);
|
|
303
413
|
// With --agent the copy is in place, and a fresh one needs loading; without it, say how to add it.
|
|
304
414
|
const agentLine = agent ? plans.at(-1).change && agents[agent].loads : skillHint;
|
|
305
|
-
|
|
415
|
+
const dev = (await readPackage(cwd)).manifest.scripts?.dev;
|
|
416
|
+
stdout.write(`\n${agentLine ? `${agentLine}\n` : ''}${nextStep(typeof dev === 'string' && dev.trim().startsWith(devWrap))}\n`);
|
|
306
417
|
return 0;
|
|
307
418
|
}
|
package/src/cli/commands/save.js
CHANGED
|
@@ -31,11 +31,7 @@ export async function run(args, { cwd, stdout, stderr, buildStdio, largeHistory
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const config = await readConfig(cwd);
|
|
34
|
-
|
|
35
|
-
const { version, unchanged, bytes, history } = await saveVersion(
|
|
36
|
-
{ historyDir: path.resolve(cwd, config.history), sourceDir: path.resolve(cwd, config.source), exclude: await sourceExcludes(cwd, config) },
|
|
37
|
-
{ name, notes: parsed.values.note ?? [] }
|
|
38
|
-
);
|
|
34
|
+
const { version, unchanged, bytes, history } = await saveProject({ projectDir: cwd, config, name, notes: parsed.values.note ?? [], buildStdio });
|
|
39
35
|
if (unchanged) {
|
|
40
36
|
stdout.write(`Nothing changed since ${version.id} "${version.name}". Nothing was saved.\n`);
|
|
41
37
|
return 0;
|
|
@@ -49,7 +45,17 @@ export async function run(args, { cwd, stdout, stderr, buildStdio, largeHistory
|
|
|
49
45
|
return 0;
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
|
|
48
|
+
// Builds the prototype when it has a build, then saves it as the next version. Shared by this command and the overlay's
|
|
49
|
+
// Save button, which reaches it through afterimage dev.
|
|
50
|
+
export async function saveProject({ projectDir, config, name, notes = [], buildStdio }) {
|
|
51
|
+
if (config.build) await runBuild(config.build, projectDir, { stdio: buildStdio });
|
|
52
|
+
return saveVersion(
|
|
53
|
+
{ historyDir: path.resolve(projectDir, config.history), sourceDir: path.resolve(projectDir, config.source), exclude: await sourceExcludes(projectDir, config) },
|
|
54
|
+
{ name, notes }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const sizeDocs ='https://github.com/uxlucas/Afterimage/blob/main/docs/limits.md#history-size';
|
|
53
59
|
|
|
54
60
|
// Decimal units, as macOS Finder and npm count them.
|
|
55
61
|
export function formatSize(bytes) {
|
package/src/cli/hosts.js
CHANGED
|
@@ -21,7 +21,7 @@ export const cleanUrlRewrite = { source: '/:path((?!afterimage/|afterimage$).+)'
|
|
|
21
21
|
// own builder takes over: @vercel/next finds .next/export-detail.json after the build and serves the export's folder (out/),
|
|
22
22
|
// not outputDirectory, so /afterimage/ would not deploy. Vercel detects the framework from package.json, so this is keyed
|
|
23
23
|
// on a dependency on next and not on the type init chose. It costs the live app the builder's _next/static caching and its
|
|
24
|
-
// .html stripping, which the rewrite below
|
|
24
|
+
// .html stripping, which nextStaticCache and the rewrite below bring back. Other presets (Vite, Astro, SvelteKit) run in
|
|
25
25
|
// @vercel/static-build, which uses outputDirectory ahead of the preset's own folder, and null would cost them their cache
|
|
26
26
|
// headers or SPA fallback, so they are left as they are.
|
|
27
27
|
// Source: https://github.com/vercel/vercel/blob/main/packages/next/src/index.ts
|
|
@@ -32,6 +32,15 @@ const hostSettings = (next) => (next ? { framework: null, buildCommand: hostBuil
|
|
|
32
32
|
// is not caught and nothing loops.
|
|
33
33
|
export const slashRedirect = { source: '/afterimage', destination: '/afterimage/' };
|
|
34
34
|
|
|
35
|
+
// The long cache on a Next.js app's hashed files (/_next/static: content-hashed names, or the build id in the path), which
|
|
36
|
+
// Vercel's Next.js builder and Netlify's Next.js runtime set and which framework: null and NETLIFY_NEXT_PLUGIN_SKIP give up.
|
|
37
|
+
// Both hosts then serve them with max-age=0 (seen on real deploys, docs/checking-a-deploy.md). The source matches the live
|
|
38
|
+
// app's paths only: a version's copies, under /afterimage/versions/vN/_next/static/, keep the host's default.
|
|
39
|
+
const immutable = 'public, max-age=31536000, immutable';
|
|
40
|
+
export const nextStaticCache = { source: '/_next/static/(.*)', headers: [{ key: 'Cache-Control', value: immutable }] };
|
|
41
|
+
// Any rule of the project's own for those paths counts, whatever it sets.
|
|
42
|
+
const isStaticCache = rule => typeof rule?.source === 'string' && rule.source.trim().startsWith('/_next/static/');
|
|
43
|
+
|
|
35
44
|
// A rule counts as ours when it points at <path>.html and keeps /afterimage out, however the rest is spelled.
|
|
36
45
|
const isCleanUrlRewrite = rule => typeof rule?.destination === 'string' && /^\/:\w+\*?\.html$/.test(rule.destination) && typeof rule.source === 'string' && rule.source.includes('afterimage');
|
|
37
46
|
const fromAfterimage = rule => typeof rule?.source === 'string' && rule.source.trim().replace(/\/+$/, '') === slashRedirect.source;
|
|
@@ -75,7 +84,8 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
75
84
|
const file = path.join(cwd, 'vercel.json');
|
|
76
85
|
const { bom, body: before } = await readJsonText(file);
|
|
77
86
|
const settings = before === null ? {} : parseJson(before, 'vercel.json');
|
|
78
|
-
const
|
|
87
|
+
const next = await usesNext(cwd);
|
|
88
|
+
const wanted = hostSettings(next);
|
|
79
89
|
const notes = [];
|
|
80
90
|
if (await dependsOn(cwd, '@astrojs/vercel')) notes.push(astroVercelNote);
|
|
81
91
|
|
|
@@ -101,6 +111,7 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
101
111
|
lists.redirects = planList(settings, 'redirects', slashRedirect, isSlashRedirect, 'so /afterimage opens the viewer');
|
|
102
112
|
}
|
|
103
113
|
}
|
|
114
|
+
if (next) lists.headers = planList(settings, 'headers', nextStaticCache, isStaticCache, "for the long cache on the live app's /_next/static");
|
|
104
115
|
const added = {};
|
|
105
116
|
for (const [key, plan] of Object.entries(lists)) {
|
|
106
117
|
if (plan.note) notes.push(plan.note);
|
|
@@ -116,14 +127,14 @@ export async function planVercel(cwd, { isTTY, ask }) {
|
|
|
116
127
|
if (!agreed) {
|
|
117
128
|
// Values to set, and entries to add to lists the project may already have: pasting must never replace its own rules.
|
|
118
129
|
const lines = [`Left vercel.json as it is. To deploy the history with Afterimage, set:\n${JSON.stringify(wanted, null, 2).slice(2, -2)}`];
|
|
119
|
-
for (const [key, entry] of [['rewrites', cleanUrlRewrite], ['redirects', slashRedirect]]) {
|
|
130
|
+
for (const [key, entry] of [['rewrites', cleanUrlRewrite], ['redirects', slashRedirect], ['headers', nextStaticCache]]) {
|
|
120
131
|
if (key in added) lines.push(`and add this to "${key}", keeping the rules already there:\n${JSON.stringify(entry, null, 2)}`);
|
|
121
132
|
}
|
|
122
133
|
return { change: null, notes: [lines.join('\n'), ...notes], apply: null };
|
|
123
134
|
}
|
|
124
135
|
set.unshift(...differs.filter(key => !set.includes(key)));
|
|
125
136
|
}
|
|
126
|
-
const order = [...Object.keys(wanted), 'rewrites', 'redirects'];
|
|
137
|
+
const order = [...Object.keys(wanted), 'rewrites', 'redirects', 'headers'];
|
|
127
138
|
const text = JSON.stringify({ ...settings, ...wanted, ...added }, null, 2);
|
|
128
139
|
return {
|
|
129
140
|
change: before === null ? 'Created vercel.json' : `Updated vercel.json (set ${order.filter(key => set.includes(key)).join(', ')})`,
|
|
@@ -151,24 +162,30 @@ const usesNext = cwd => dependsOn(cwd, 'next');
|
|
|
151
162
|
// deploy. NETLIFY_NEXT_PLUGIN_SKIP makes it step aside. Read in opennextjs-netlify's source (src/index.ts,
|
|
152
163
|
// src/build/content/static.ts), not tried on a real deploy. Netlify answers /about from about.html on its own.
|
|
153
164
|
const skipRuntime = 'NETLIFY_NEXT_PLUGIN_SKIP';
|
|
165
|
+
// nextStaticCache, for Netlify. Its paths match the live app's only, as on Vercel.
|
|
166
|
+
const netlifyStaticCache = `[[headers]]\n for = "/_next/static/*"\n [headers.values]\n Cache-Control = "${immutable}"\n`;
|
|
154
167
|
|
|
155
168
|
export async function planNetlify(cwd) {
|
|
156
169
|
const file = path.join(cwd, 'netlify.toml');
|
|
157
170
|
const before = await readText(file);
|
|
158
171
|
const next = await usesNext(cwd);
|
|
159
172
|
if (before === null) {
|
|
160
|
-
const
|
|
161
|
-
return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${
|
|
173
|
+
const nextOnly = next ? `\n[build.environment]\n ${skipRuntime} = "true"\n\n${netlifyStaticCache}` : '';
|
|
174
|
+
return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${nextOnly}`) };
|
|
162
175
|
}
|
|
163
176
|
// TOML is not parsed or rewritten here: a hand edit is safer than a guessed merge.
|
|
164
177
|
const hasCommand = new RegExp(`^\\s*command\\s*=\\s*["']${hostBuild}["']`, 'm').test(before);
|
|
165
178
|
const hasPublish = new RegExp(`^\\s*publish\\s*=\\s*["']${hostOutput.replace('.', '\\.')}["']`, 'm').test(before);
|
|
166
179
|
const hasSkip = new RegExp(`^\\s*${skipRuntime}\\s*=\\s*["']?(true|1)["']?\\s*$`, 'm').test(before);
|
|
180
|
+
// Any [[headers]] rule of the project's own for those paths counts, whatever it sets.
|
|
181
|
+
const hasCache = /^\s*for\s*=\s*["']\/_next\/static\//m.test(before);
|
|
167
182
|
const wantsSkip = next && !hasSkip;
|
|
168
|
-
|
|
183
|
+
const wantsCache = next && !hasCache;
|
|
184
|
+
if (hasCommand && hasPublish && !wantsSkip && !wantsCache) return { change: null, notes: [], apply: null };
|
|
169
185
|
const lines = [];
|
|
170
186
|
if (!hasCommand || !hasPublish) lines.push(`Under [build], set:\n command = "${hostBuild}"\n publish = "${hostOutput}"`);
|
|
171
187
|
if (wantsSkip) lines.push(`Under [build.environment], set (so Netlify's Next.js runtime leaves the history in place instead of deploying out/):\n ${skipRuntime} = "true"`);
|
|
188
|
+
if (wantsCache) lines.push(`To keep the long cache on the live app's /_next/static, which the runtime would have set, add:\n${netlifyStaticCache.trimEnd()}`);
|
|
172
189
|
return {
|
|
173
190
|
change: null,
|
|
174
191
|
notes: [`netlify.toml already exists, so Afterimage left it alone. ${lines.join('\n')}`],
|
package/src/cli/site.js
CHANGED
|
@@ -65,7 +65,7 @@ async function checkCollision(sourceDir, excluded) {
|
|
|
65
65
|
// With includeApp false (the dev server) only the afterimage/ folder is made. The old out stays in place until the
|
|
66
66
|
// new one is complete, so a failure anywhere leaves the previous site untouched.
|
|
67
67
|
// Returns warnings for the caller to print. fsOps lets tests make the swap fail; it is not part of the public signature.
|
|
68
|
-
export async function assembleSite({ projectDir, config, viewerDir = packagedViewerDir, runtimeDir = packagedRuntimeDir, includeApp = true, buildStdio, fsOps = {} }) {
|
|
68
|
+
export async function assembleSite({ projectDir, config, viewerDir = packagedViewerDir, runtimeDir = packagedRuntimeDir, includeApp = true, allowEmpty = false, buildStdio, fsOps = {} }) {
|
|
69
69
|
const ops = { rename: fs.rename, rm: fs.rm, ...fsOps };
|
|
70
70
|
const historyDir = path.resolve(projectDir, config.history);
|
|
71
71
|
const sourceDir = path.resolve(projectDir, config.source);
|
|
@@ -73,7 +73,8 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
|
|
|
73
73
|
const warnings = [];
|
|
74
74
|
|
|
75
75
|
const manifest = await verifyHistory(historyDir);
|
|
76
|
-
|
|
76
|
+
// Dev may start before the first save, so the overlay can offer it; a deploy needs something to show.
|
|
77
|
+
if (!manifest?.versions.length && !allowEmpty) throw new Error('Save a first version with: afterimage save "Name"');
|
|
77
78
|
try {
|
|
78
79
|
await fs.access(path.join(viewerDir, 'index.html'));
|
|
79
80
|
} catch {
|
|
@@ -130,8 +131,8 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
|
|
|
130
131
|
}
|
|
131
132
|
await fs.cp(viewerDir, served, { recursive: true });
|
|
132
133
|
// Only what versions.json lists. The history folder also holds the save lock and stage folders.
|
|
133
|
-
await fs.copyFile(path.join(historyDir, 'versions.json'), path.join(served, 'versions.json'));
|
|
134
|
-
for (const { id } of manifest
|
|
134
|
+
if (manifest) await fs.copyFile(path.join(historyDir, 'versions.json'), path.join(served, 'versions.json'));
|
|
135
|
+
for (const { id } of manifest?.versions ?? []) {
|
|
135
136
|
await fs.cp(path.join(historyDir, 'versions', id), path.join(served, 'versions', id), { recursive: true });
|
|
136
137
|
}
|
|
137
138
|
// map.js is for node (history.js injects with it); frame.js and sw.js carry their own copies of what they need,
|
|
@@ -167,5 +168,5 @@ export async function assembleSite({ projectDir, config, viewerDir = packagedVie
|
|
|
167
168
|
await fs.rm(staging, keep).catch(() => {});
|
|
168
169
|
throw error;
|
|
169
170
|
}
|
|
170
|
-
return { outDir, versions: manifest
|
|
171
|
+
return { outDir, versions: manifest?.versions.length ?? 0, warnings };
|
|
171
172
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The overlay: Afterimage's pill on the prototype itself, served at /afterimage/overlay.js and exported as
|
|
2
|
+
// afterimage-cli/overlay. One line adds it to an app: a script tag in plain HTML, or an import in a bundled app.
|
|
3
|
+
//
|
|
4
|
+
// It finds the history on its own and draws nothing when there is none:
|
|
5
|
+
// - on this machine, afterimage dev answers /afterimage/api/session, on the page's own origin (a plain prototype dev
|
|
6
|
+
// serves itself) or on ports 4321 to 4330 (beside the app's own dev server). There the pill can also save.
|
|
7
|
+
// - on a built site, /afterimage/versions.json is next to the app, and the pill shows the history only.
|
|
8
|
+
// How this copy was loaded says which, so a built site never asks for a helper, even served from this machine:
|
|
9
|
+
// - a script tag dev served carries dev's stamp (window.__afterimageDev); any other script tag is a built site's copy.
|
|
10
|
+
// - an import has no currentScript. Bundlers replace process.env.NODE_ENV, so a production build says it is one.
|
|
11
|
+
// History opens the viewer as a layer over the page, in the same tab; the close button, Esc, or the viewer itself
|
|
12
|
+
// (it posts afterimage:close when it is framed) close it again.
|
|
13
|
+
//
|
|
14
|
+
// It is plain JavaScript with no imports, so it runs as a classic script and as a module, and it does nothing where
|
|
15
|
+
// there is no document (a server render of a bundled app). It draws in a shadow root, so the app's CSS can't reach it.
|
|
16
|
+
// It stays out of saved versions, which are builds of the same app: frame.js marks those pages before any app code
|
|
17
|
+
// runs, and any page in a frame is left alone too.
|
|
18
|
+
(() => {
|
|
19
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
20
|
+
if (window.__afterimageOverlay || window.__afterimageVersion) return;
|
|
21
|
+
try {
|
|
22
|
+
if (window.top !== window) return;
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
window.__afterimageOverlay = true;
|
|
27
|
+
|
|
28
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname) || location.hostname.endsWith('.localhost');
|
|
29
|
+
const scriptTag = Boolean(document.currentScript);
|
|
30
|
+
const servedByDev = window.__afterimageDev === true;
|
|
31
|
+
let mode;
|
|
32
|
+
try {
|
|
33
|
+
// Written out in full so a bundler can replace it; with no bundler there is no process and this throws.
|
|
34
|
+
mode = process.env.NODE_ENV;
|
|
35
|
+
} catch { /* a script tag */ }
|
|
36
|
+
const inDev = local && (scriptTag ? servedByDev : mode !== 'production');
|
|
37
|
+
const helperPorts = Array.from({ length: 10 }, (_, i) => 4321 + i);
|
|
38
|
+
|
|
39
|
+
const getJson = async (url, timeout = 1500) => {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeout) });
|
|
42
|
+
return response.ok ? await response.json() : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const session = async base => {
|
|
48
|
+
const body = await getJson(`${base}/afterimage/api/session`);
|
|
49
|
+
return body?.afterimage === 1 ? { base, ...body } : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Where the history is, or null. On this machine a running afterimage dev comes first: some dev servers (Vite's)
|
|
53
|
+
// serve the project's own afterimage/versions.json at the same path a deployed site does, and that copy can't save.
|
|
54
|
+
// A host that answers every path with the app's own page fails the JSON checks.
|
|
55
|
+
async function find() {
|
|
56
|
+
if (inDev && servedByDev) {
|
|
57
|
+
const here = await session(location.origin);
|
|
58
|
+
if (here) return here;
|
|
59
|
+
} else if (inDev) {
|
|
60
|
+
// One port at a time from 4321, stopping at the first helper: usually that is the first ask, and a port held by
|
|
61
|
+
// some other server costs one quick refusal (which the browser logs) rather than ten at once.
|
|
62
|
+
for (const port of helperPorts) {
|
|
63
|
+
const found = await session(`http://${location.hostname}:${port}`);
|
|
64
|
+
if (found) return found;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const manifest = await getJson('/afterimage/versions.json');
|
|
68
|
+
if (Array.isArray(manifest?.versions) && manifest.versions.length) {
|
|
69
|
+
const latest = manifest.versions[0];
|
|
70
|
+
return { base: location.origin, versions: manifest.versions.length, latest: { id: latest.id, name: latest.name }, canSave: false };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const css = `
|
|
76
|
+
:host { all: initial; }
|
|
77
|
+
* { box-sizing: border-box; }
|
|
78
|
+
.dock { position: fixed; right: 16px; bottom: 16px; z-index: 2147483646; font: 13px/1 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
79
|
+
-webkit-font-smoothing: antialiased; color: #ededed; }
|
|
80
|
+
.pill { display: flex; align-items: center; gap: 2px; padding: 5px; border-radius: 999px; background: #1b1b1b;
|
|
81
|
+
box-shadow: 0 0 0 1px #ffffff14, 0 1px 2px #0000004d, 0 10px 28px #00000040; user-select: none; -webkit-user-select: none; }
|
|
82
|
+
.mark { flex: none; width: 34px; height: 34px; display: grid; place-items: center; }
|
|
83
|
+
.label { padding: 0 8px 0 2px; color: #8e8e8e; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
84
|
+
.label b { color: #ededed; font-weight: 500; }
|
|
85
|
+
.sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: #ffffff14; }
|
|
86
|
+
button { appearance: none; height: 34px; margin: 0; padding: 0 12px; border: 0; border-radius: 999px; background: transparent;
|
|
87
|
+
color: inherit; font: inherit; line-height: 1; white-space: nowrap; cursor: pointer; }
|
|
88
|
+
button:hover:not(:disabled) { background: #2a2a2a; }
|
|
89
|
+
button:disabled { color: #5c5c5c; cursor: default; }
|
|
90
|
+
button:focus-visible, input:focus-visible { outline: 2px solid #ededed8c; outline-offset: 1px; }
|
|
91
|
+
.primary { background: #ededed; color: #1b1b1b; }
|
|
92
|
+
.primary:hover:not(:disabled) { background: #ffffff; }
|
|
93
|
+
input { width: 180px; height: 34px; margin: 0 2px 0 0; padding: 0 12px; border: 0; border-radius: 999px; background: #2a2a2a;
|
|
94
|
+
color: #ededed; font: inherit; }
|
|
95
|
+
input::placeholder { color: #8e8e8e; }
|
|
96
|
+
.status { padding: 0 12px 0 2px; white-space: nowrap; }
|
|
97
|
+
.status.bad { color: #e0705f; white-space: normal; max-width: 320px; line-height: 1.35; padding: 6px 12px 6px 2px; }
|
|
98
|
+
.busy { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: #e5a83b;
|
|
99
|
+
vertical-align: 1px; animation: pulse 1s ease-in-out infinite alternate; }
|
|
100
|
+
@keyframes pulse { from { opacity: 0.35; } to { opacity: 1; } }
|
|
101
|
+
.layer { position: fixed; inset: 0; z-index: 2147483647; background: #1b1b1b; opacity: 0; transition: opacity 160ms ease-out; }
|
|
102
|
+
.layer[data-shown] { opacity: 1; }
|
|
103
|
+
.layer iframe { display: block; width: 100%; height: 100%; border: 0; }
|
|
104
|
+
.close { position: absolute; top: 12px; right: 12px; width: 34px; padding: 0; display: grid; place-items: center; background: #1b1b1b;
|
|
105
|
+
box-shadow: 0 0 0 1px #ffffff14, 0 6px 16px #00000040; color: #ededed; }
|
|
106
|
+
.close:hover:not(:disabled) { background: #2a2a2a; }
|
|
107
|
+
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
108
|
+
@media (prefers-reduced-motion: reduce) { .layer { transition: none; } .busy { animation: none; } }
|
|
109
|
+
`;
|
|
110
|
+
// The wipe mark from afterimage's own icon: the first version on the left, today's on the right.
|
|
111
|
+
const mark = '<svg width="18" height="18" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#ededed"/><path d="M7 0h9v32H7a7 7 0 0 1-7-7V7a7 7 0 0 1 7-7z" fill="#8e8e8e"/><rect x="15" y="5" width="2" height="22" rx="1" fill="#1b1b1b"/></svg>';
|
|
112
|
+
const cross = '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
|
113
|
+
|
|
114
|
+
const host = document.createElement('afterimage-overlay');
|
|
115
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
116
|
+
const make = (tag, props = {}) => Object.assign(document.createElement(tag), props);
|
|
117
|
+
|
|
118
|
+
let state = null;
|
|
119
|
+
let view = 'idle';
|
|
120
|
+
let message = '';
|
|
121
|
+
let timer = 0;
|
|
122
|
+
let layer = null;
|
|
123
|
+
let returnFocus = null;
|
|
124
|
+
|
|
125
|
+
const dock = make('div', { className: 'dock' });
|
|
126
|
+
const pill = make('div', { className: 'pill' });
|
|
127
|
+
pill.setAttribute('role', 'toolbar');
|
|
128
|
+
pill.setAttribute('aria-label', 'Afterimage');
|
|
129
|
+
dock.append(pill);
|
|
130
|
+
const live = make('div', { className: 'visually-hidden' });
|
|
131
|
+
live.setAttribute('aria-live', 'polite');
|
|
132
|
+
root.append(make('style', { textContent: css }), dock, live);
|
|
133
|
+
|
|
134
|
+
const say = text => { live.textContent = text; };
|
|
135
|
+
const later = (next, ms) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
timer = setTimeout(next, ms);
|
|
138
|
+
};
|
|
139
|
+
const button = (text, onClick, extra = {}) => {
|
|
140
|
+
const element = make('button', { type: 'button', textContent: text, ...extra });
|
|
141
|
+
element.addEventListener('click', onClick);
|
|
142
|
+
return element;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function render() {
|
|
146
|
+
pill.replaceChildren();
|
|
147
|
+
pill.insertAdjacentHTML('beforeend', `<span class="mark">${mark}</span>`);
|
|
148
|
+
const versions = state.versions;
|
|
149
|
+
|
|
150
|
+
if (view === 'naming') {
|
|
151
|
+
const input = make('input', { type: 'text', maxLength: 120, placeholder: `Version ${versions + 1}`, ariaLabel: 'Version name' });
|
|
152
|
+
input.addEventListener('keydown', event => {
|
|
153
|
+
if (event.key === 'Enter') save(input.value);
|
|
154
|
+
if (event.key === 'Escape') { event.stopPropagation(); back(); }
|
|
155
|
+
});
|
|
156
|
+
pill.append(input, button('Save', () => save(input.value), { className: 'primary' }), button('Cancel', back));
|
|
157
|
+
input.focus();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (view === 'saving') {
|
|
161
|
+
pill.append(make('span', { className: 'status', innerHTML: `<span class="busy"></span>Saving v${versions + 1}…` }));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (view === 'saved' || view === 'failed') {
|
|
165
|
+
pill.append(make('span', { className: `status${view === 'failed' ? ' bad' : ''}`, textContent: message }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const label = make('span', { className: 'label' });
|
|
170
|
+
if (versions) label.innerHTML = `<b>${state.latest.id}</b> · ${versions} ${versions === 1 ? 'version' : 'versions'}`;
|
|
171
|
+
else label.textContent = 'No versions yet';
|
|
172
|
+
pill.append(label, make('span', { className: 'sep' }));
|
|
173
|
+
if (versions) pill.append(button('History', open));
|
|
174
|
+
if (state.canSave) pill.append(button(versions ? 'Save' : 'Save first version', () => { view = 'naming'; render(); }, versions ? {} : { className: 'primary' }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function back() {
|
|
178
|
+
view = 'idle';
|
|
179
|
+
render();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function save(name) {
|
|
183
|
+
view = 'saving';
|
|
184
|
+
render();
|
|
185
|
+
say('Saving. Afterimage builds the prototype first.');
|
|
186
|
+
let result;
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(`${state.base}/afterimage/api/save`, {
|
|
189
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim() })
|
|
190
|
+
});
|
|
191
|
+
result = await response.json().catch(() => ({ error: `Afterimage answered ${response.status}.` }));
|
|
192
|
+
if (!response.ok) throw new Error(result.error ?? `Afterimage answered ${response.status}.`);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
view = 'failed';
|
|
195
|
+
message = `Couldn't save. ${error.message === 'Failed to fetch' ? 'Is afterimage dev still running?' : error.message}`;
|
|
196
|
+
render();
|
|
197
|
+
say(message);
|
|
198
|
+
later(back, 6000);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (result.saved) state = { ...state, versions: Number(result.id.slice(1)), latest: { id: result.id, name: result.name } };
|
|
202
|
+
view = 'saved';
|
|
203
|
+
message = result.saved ? `Saved ${result.id} "${result.name}"` : `No changes since ${result.id}`;
|
|
204
|
+
render();
|
|
205
|
+
say(message);
|
|
206
|
+
later(back, 2400);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function open(event) {
|
|
210
|
+
if (layer) return;
|
|
211
|
+
returnFocus = event?.currentTarget ?? null;
|
|
212
|
+
layer = make('div', { className: 'layer' });
|
|
213
|
+
layer.setAttribute('role', 'dialog');
|
|
214
|
+
layer.setAttribute('aria-modal', 'true');
|
|
215
|
+
layer.setAttribute('aria-label', 'Version history');
|
|
216
|
+
const frame = make('iframe', { src: `${state.base}/afterimage/`, title: 'Version history' });
|
|
217
|
+
const closer = button('', close, { className: 'close', ariaLabel: 'Close history' });
|
|
218
|
+
closer.innerHTML = cross;
|
|
219
|
+
layer.append(frame, closer);
|
|
220
|
+
root.append(layer);
|
|
221
|
+
dock.hidden = true;
|
|
222
|
+
document.documentElement.style.setProperty('overflow', 'hidden');
|
|
223
|
+
requestAnimationFrame(() => layer?.setAttribute('data-shown', ''));
|
|
224
|
+
frame.addEventListener('load', () => frame.focus(), { once: true });
|
|
225
|
+
closer.focus();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function close() {
|
|
229
|
+
if (!layer) return;
|
|
230
|
+
layer.remove();
|
|
231
|
+
layer = null;
|
|
232
|
+
dock.hidden = false;
|
|
233
|
+
document.documentElement.style.removeProperty('overflow');
|
|
234
|
+
returnFocus?.focus?.();
|
|
235
|
+
// A save the agent made from the terminal meanwhile shows up in the count.
|
|
236
|
+
refresh();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
window.addEventListener('keydown', event => {
|
|
240
|
+
if (event.key === 'Escape' && layer) close();
|
|
241
|
+
});
|
|
242
|
+
window.addEventListener('message', event => {
|
|
243
|
+
if (layer && event.origin === new URL(state.base).origin && event.data?.type === 'afterimage:close') close();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
async function refresh() {
|
|
247
|
+
if (!state || view !== 'idle') return;
|
|
248
|
+
const next = state.canSave ? await session(state.base) : null;
|
|
249
|
+
if (next && (next.versions !== state.versions)) {
|
|
250
|
+
state = next;
|
|
251
|
+
render();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Saves the agent makes from the terminal show up when the person comes back to the page.
|
|
255
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(); });
|
|
256
|
+
|
|
257
|
+
const mount = async () => {
|
|
258
|
+
state = await find();
|
|
259
|
+
if (!state) return;
|
|
260
|
+
render();
|
|
261
|
+
// On <html>, beside <body> rather than in it: frameworks that redraw the body (hydration, page swaps) leave it alone.
|
|
262
|
+
document.documentElement.append(host);
|
|
263
|
+
};
|
|
264
|
+
if (document.body) mount();
|
|
265
|
+
else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
266
|
+
})();
|