mechanica 2.1.0-beta.4 → 2.1.0-beta.6
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 +3 -2
- package/dist/chunks/assets-nujjbCx8.js +322 -0
- package/dist/chunks/{node-adapter-BJbGKMgx.js → node-adapter-CSkDXYGO.js} +5 -36
- package/dist/chunks/{rich-text-codec-DcgyZKq_.js → rich-text-codec-BSD35k2U.js} +138 -1
- package/dist/chunks/{site-DHrH4i9x.js → site-DfazSr3_.js} +1 -1
- package/dist/cli.js +75 -158
- package/dist/export.js +65 -39
- package/dist/plugin.js +15 -5
- package/dist/server.js +4 -4
- package/dist/types/cli/export.d.ts +27 -1
- package/dist/types/cli/platform/assets.d.ts +51 -0
- package/dist/types/cli/platform/link.d.ts +8 -0
- package/dist/types/cli/platform/push.d.ts +2 -1
- package/dist/types/cli/run-export.d.ts +9 -0
- package/dist/types/server/assets-store.d.ts +50 -0
- package/dist/types/server/index.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -360,8 +360,9 @@ These drive a locally installed Chrome or Edge (or `--browser <path>`) and need
|
|
|
360
360
|
| `mechanica thumbs [--blocks]` | Regenerate page or block thumbnails for the editor |
|
|
361
361
|
| `mechanica images` | Generate image dimensions and blur-up previews for `.mech/assets` (needs `sharp`; `--force` redoes all) |
|
|
362
362
|
| `mechanica login` | Sign in to the Mechanica platform and save an access token for this machine (`--host`, `--token`) |
|
|
363
|
-
| `mechanica link <slug>` | Point this repository at a site on the platform (`--create` makes the site) |
|
|
364
|
-
| `mechanica push` | Publish the committed project to the platform: pull what was edited online, `git push`, build and upload the bundle when the code changed, wait for the deploy |
|
|
363
|
+
| `mechanica link <slug>` | Point this repository at a site on the platform (`--create` makes the site); uploads are then kept by the platform, so `.mech/assets/` goes into `.gitignore` |
|
|
364
|
+
| `mechanica push` | Publish the committed project to the platform: pull what was edited online, send the uploads it lacks, `git push`, build and upload the bundle when the code changed, wait for the deploy |
|
|
365
|
+
| `mechanica assets pull` | Download the uploads made in the platform's online editor that this project lacks (the dev server fetches them one by one on demand) |
|
|
365
366
|
|
|
366
367
|
## Plugin options
|
|
367
368
|
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { G as isAssetName, J as readImageManifest, X as updateImageManifest } from "./rich-text-codec-BSD35k2U.js";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { chmod, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
/** Normalize a host argument to an origin (`https://example.com`, no trailing slash or path). */
|
|
7
|
+
function hostOrigin(host) {
|
|
8
|
+
const value = host ?? process.env.MECHANICA_HOST ?? "https://mechanica.jt3.ru";
|
|
9
|
+
return new URL(/^https?:\/\//.test(value) ? value : `https://${value}`).origin;
|
|
10
|
+
}
|
|
11
|
+
/** Where access tokens are kept: one file per user, outside any project. */
|
|
12
|
+
function credentialsFile() {
|
|
13
|
+
return join(process.env.MECHANICA_CONFIG_DIR ?? join(os.homedir(), ".mechanica"), "credentials.json");
|
|
14
|
+
}
|
|
15
|
+
async function readAll() {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(await readFile(credentialsFile(), "utf-8"));
|
|
18
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The access token for a platform origin: `MECHANICA_TOKEN` (CI) wins over the
|
|
25
|
+
* one `mechanica login` saved.
|
|
26
|
+
*/
|
|
27
|
+
async function getToken(origin) {
|
|
28
|
+
if (process.env.MECHANICA_TOKEN) return process.env.MECHANICA_TOKEN;
|
|
29
|
+
return (await readAll())[origin] ?? null;
|
|
30
|
+
}
|
|
31
|
+
/** Remember the token for an origin, in a file only the user can read. */
|
|
32
|
+
async function saveToken(origin, token) {
|
|
33
|
+
const file = credentialsFile();
|
|
34
|
+
const all = await readAll();
|
|
35
|
+
all[origin] = token;
|
|
36
|
+
await mkdir(join(file, ".."), { recursive: true });
|
|
37
|
+
await writeFile(file, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
38
|
+
await chmod(file, 384).catch(() => {});
|
|
39
|
+
return file;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/cli/platform/api.ts
|
|
43
|
+
/** A failed platform request: the HTTP status plus the message the platform gave. */
|
|
44
|
+
var PlatformError = class extends Error {
|
|
45
|
+
status;
|
|
46
|
+
constructor(status, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.status = status;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** The platform answers errors as plain text or as `{ error: { field: { message } } }`. */
|
|
52
|
+
function errorMessage(status, text) {
|
|
53
|
+
try {
|
|
54
|
+
const error = JSON.parse(text)?.error;
|
|
55
|
+
if (typeof error === "string") return error;
|
|
56
|
+
if (error && typeof error === "object") {
|
|
57
|
+
const messages = Object.values(error).map((item) => item?.message).filter((message) => typeof message === "string");
|
|
58
|
+
if (messages.length) return messages.join("; ");
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
return text.trim() || `Request failed (${status})`;
|
|
62
|
+
}
|
|
63
|
+
/** One request to the platform API; resolves with the parsed JSON body (or null). */
|
|
64
|
+
async function request(origin, method, path, options = {}) {
|
|
65
|
+
const headers = {};
|
|
66
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
67
|
+
if (options.raw) headers["content-type"] = "application/octet-stream";
|
|
68
|
+
else if (options.body !== void 0) headers["content-type"] = "application/json";
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(origin + path, {
|
|
72
|
+
method,
|
|
73
|
+
headers,
|
|
74
|
+
body: options.raw ?? (options.body !== void 0 ? JSON.stringify(options.body) : void 0)
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new PlatformError(0, `Could not reach ${origin} (${error.message})`);
|
|
78
|
+
}
|
|
79
|
+
const text = await response.text();
|
|
80
|
+
if (!response.ok) throw new PlatformError(response.status, errorMessage(response.status, text));
|
|
81
|
+
return text ? JSON.parse(text) : null;
|
|
82
|
+
}
|
|
83
|
+
/** The saved token for `origin`, or an error telling the user to log in. */
|
|
84
|
+
async function requireToken(origin) {
|
|
85
|
+
const token = await getToken(origin);
|
|
86
|
+
if (!token) throw new Error(`Not logged in to ${origin} — run \`mechanica login\` first`);
|
|
87
|
+
return token;
|
|
88
|
+
}
|
|
89
|
+
/** The name of the git remote `mechanica link` manages. */
|
|
90
|
+
var REMOTE_NAME = "mechanica";
|
|
91
|
+
/**
|
|
92
|
+
* Split a site's git remote URL (`https://host/git/<slug>.git`) into the
|
|
93
|
+
* platform origin and the slug; null for a URL that is not one of ours.
|
|
94
|
+
*/
|
|
95
|
+
function parseRemoteUrl(url) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = new URL(url);
|
|
98
|
+
const match = /^\/git\/([a-z0-9][a-z0-9-]*[a-z0-9])\.git\/?$/.exec(parsed.pathname);
|
|
99
|
+
if (!match || !/^https?:$/.test(parsed.protocol)) return null;
|
|
100
|
+
return {
|
|
101
|
+
origin: parsed.origin,
|
|
102
|
+
slug: match[1]
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/cli/platform/git.ts
|
|
110
|
+
/**
|
|
111
|
+
* Environment that makes git send the access token to one origin only, as HTTP
|
|
112
|
+
* Basic credentials. Passed through `GIT_CONFIG_*` variables so the token never
|
|
113
|
+
* appears on a command line (visible to other processes) or in the repo config.
|
|
114
|
+
*/
|
|
115
|
+
function gitAuthEnv(origin, token) {
|
|
116
|
+
const basic = Buffer.from(`mechanica:${token}`).toString("base64");
|
|
117
|
+
return {
|
|
118
|
+
GIT_CONFIG_COUNT: "1",
|
|
119
|
+
GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
|
|
120
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
|
121
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Run git in `cwd` and collect its output; never throws on a non-zero exit. */
|
|
125
|
+
function git(cwd, args, env = {}) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const child = spawn("git", args, {
|
|
128
|
+
cwd,
|
|
129
|
+
env: {
|
|
130
|
+
...process.env,
|
|
131
|
+
...env
|
|
132
|
+
},
|
|
133
|
+
windowsHide: true
|
|
134
|
+
});
|
|
135
|
+
let stdout = "";
|
|
136
|
+
let stderr = "";
|
|
137
|
+
child.stdout.on("data", (chunk) => stdout += chunk);
|
|
138
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
139
|
+
child.on("error", (error) => reject(error.code === "ENOENT" ? /* @__PURE__ */ new Error("git is not installed (or not on PATH)") : error));
|
|
140
|
+
child.on("close", (code) => resolve({
|
|
141
|
+
code: code ?? 1,
|
|
142
|
+
stdout: stdout.trim(),
|
|
143
|
+
stderr: stderr.trim()
|
|
144
|
+
}));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Like {@link git}, but a non-zero exit throws with git's own message. */
|
|
148
|
+
async function gitOk(cwd, args, env = {}) {
|
|
149
|
+
const result = await git(cwd, args, env);
|
|
150
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git ${args[0]} failed`);
|
|
151
|
+
return result.stdout;
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/cli/platform/assets.ts
|
|
155
|
+
var ASSETS_DIR = ".mech/assets";
|
|
156
|
+
/**
|
|
157
|
+
* The site this project is linked to (the `mechanica` git remote) with the
|
|
158
|
+
* saved token for it — or null when it is not linked, not logged in, or the
|
|
159
|
+
* account has no access to the site. Never throws: an unlinked project is the
|
|
160
|
+
* normal case for the dev server.
|
|
161
|
+
*/
|
|
162
|
+
async function resolveLink(cwd) {
|
|
163
|
+
try {
|
|
164
|
+
const remote = await git(cwd, [
|
|
165
|
+
"remote",
|
|
166
|
+
"get-url",
|
|
167
|
+
REMOTE_NAME
|
|
168
|
+
]);
|
|
169
|
+
const link = remote.code === 0 ? parseRemoteUrl(remote.stdout) : null;
|
|
170
|
+
if (!link) return null;
|
|
171
|
+
const token = await getToken(link.origin);
|
|
172
|
+
if (!token) return null;
|
|
173
|
+
const site = (await request(link.origin, "GET", "/api/sites", { token })).find((item) => item.slug === link.slug);
|
|
174
|
+
return site ? {
|
|
175
|
+
...link,
|
|
176
|
+
uuid: site.uuid,
|
|
177
|
+
token
|
|
178
|
+
} : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** The uploads the platform's store holds for the site. */
|
|
184
|
+
var listRemoteAssets = (link) => request(link.origin, "GET", `/api/sites/${link.uuid}/assets`, { token: link.token });
|
|
185
|
+
var fileUrl = (link, name) => `${link.origin}/api/sites/${link.uuid}/assets/file?name=${encodeURIComponent(name)}`;
|
|
186
|
+
/** One upload's bytes from the platform, or null when it has none of that name. */
|
|
187
|
+
async function downloadRemoteAsset(link, name) {
|
|
188
|
+
const response = await fetch(fileUrl(link, name), { headers: { authorization: `Bearer ${link.token}` } });
|
|
189
|
+
if (response.status === 404) return null;
|
|
190
|
+
if (!response.ok) throw new Error(`Could not download ${name} (${response.status})`);
|
|
191
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
192
|
+
}
|
|
193
|
+
var imageInfoOf = ({ width, height, previewSrc }) => ({
|
|
194
|
+
...width ? { width } : {},
|
|
195
|
+
...height ? { height } : {},
|
|
196
|
+
...previewSrc ? { previewSrc } : {}
|
|
197
|
+
});
|
|
198
|
+
/** The files in the project's uploads directory (flat: an upload is one file name). */
|
|
199
|
+
async function localAssets(cwd) {
|
|
200
|
+
const dir = join(cwd, ASSETS_DIR);
|
|
201
|
+
return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((entry) => entry.isFile() && isAssetName(entry.name)).map((entry) => entry.name);
|
|
202
|
+
}
|
|
203
|
+
/** The uploads git already carries: those reach the platform with the push itself. */
|
|
204
|
+
async function trackedAssets(cwd) {
|
|
205
|
+
const listed = await git(cwd, [
|
|
206
|
+
"ls-files",
|
|
207
|
+
"-z",
|
|
208
|
+
"--",
|
|
209
|
+
ASSETS_DIR
|
|
210
|
+
]);
|
|
211
|
+
if (listed.code !== 0) return /* @__PURE__ */ new Set();
|
|
212
|
+
return new Set(listed.stdout.split("\0").filter(Boolean).map((path) => path.slice(13)));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Send the platform the uploads this project has and it lacks — the files in
|
|
216
|
+
* `.mech/assets` that are not committed (committed ones travel with git) — with
|
|
217
|
+
* what `.mech/images.json` knows about them. Returns the names sent.
|
|
218
|
+
*/
|
|
219
|
+
async function pushUploads(cwd, link, log = console.info) {
|
|
220
|
+
const tracked = await trackedAssets(cwd);
|
|
221
|
+
const candidates = (await localAssets(cwd)).filter((name) => !tracked.has(name));
|
|
222
|
+
if (!candidates.length) return [];
|
|
223
|
+
const remote = new Set((await listRemoteAssets(link)).map((asset) => asset.name));
|
|
224
|
+
const missing = candidates.filter((name) => !remote.has(name));
|
|
225
|
+
if (!missing.length) return [];
|
|
226
|
+
log(`Uploading ${missing.length} file(s)…`);
|
|
227
|
+
for (const name of missing) {
|
|
228
|
+
const data = await readFile(join(cwd, ASSETS_DIR, name));
|
|
229
|
+
await request(link.origin, "POST", `/api/sites/${link.uuid}/assets/file?name=${encodeURIComponent(name)}`, {
|
|
230
|
+
token: link.token,
|
|
231
|
+
raw: new Uint8Array(data)
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const manifest = readImageManifest(join(cwd, ".mech"));
|
|
235
|
+
const info = {};
|
|
236
|
+
for (const name of missing) if (manifest[name]) info[name] = manifest[name];
|
|
237
|
+
if (Object.keys(info).length) await request(link.origin, "POST", `/api/sites/${link.uuid}/assets/info`, {
|
|
238
|
+
token: link.token,
|
|
239
|
+
body: info
|
|
240
|
+
});
|
|
241
|
+
return missing;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Whether git ignores the project's uploads directory — i.e. whether a file
|
|
245
|
+
* fetched into it stays out of `git status`. Decides if the dev server may
|
|
246
|
+
* keep what it fetches (see `withRemoteAssets`).
|
|
247
|
+
*/
|
|
248
|
+
async function uploadsIgnored(cwd) {
|
|
249
|
+
return (await git(cwd, [
|
|
250
|
+
"check-ignore",
|
|
251
|
+
"-q",
|
|
252
|
+
`${ASSETS_DIR}/probe.bin`
|
|
253
|
+
]).catch(() => ({ code: 1 }))).code === 0;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The platform's uploads as the dev server sees them: resolved lazily, once —
|
|
257
|
+
* a project that is not linked (or is offline) simply has none.
|
|
258
|
+
*/
|
|
259
|
+
function remoteAssetsFor(cwd) {
|
|
260
|
+
let link = null;
|
|
261
|
+
const linked = () => link ??= resolveLink(cwd);
|
|
262
|
+
let listing = null;
|
|
263
|
+
const assets = async () => {
|
|
264
|
+
const site = await linked();
|
|
265
|
+
if (!site) return [];
|
|
266
|
+
if (!listing || Date.now() - listing.at > 3e4) listing = {
|
|
267
|
+
at: Date.now(),
|
|
268
|
+
assets: listRemoteAssets(site).catch(() => [])
|
|
269
|
+
};
|
|
270
|
+
return listing.assets;
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
async fetch(name) {
|
|
274
|
+
const site = await linked();
|
|
275
|
+
if (!site) return null;
|
|
276
|
+
const data = await downloadRemoteAsset(site, name);
|
|
277
|
+
if (!data) return null;
|
|
278
|
+
const entry = (await assets()).find((asset) => asset.name === name);
|
|
279
|
+
return {
|
|
280
|
+
data,
|
|
281
|
+
...entry ? { info: imageInfoOf(entry) } : {}
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
list: async () => (await assets()).map((asset) => asset.name)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `mechanica assets pull` — download every upload the platform has and this
|
|
289
|
+
* project lacks into `.mech/assets`, with its image info. What the dev server
|
|
290
|
+
* does one file at a time, for all of them: before an offline session, a local
|
|
291
|
+
* `mechanica export`, or leaving the platform. Returns the names downloaded.
|
|
292
|
+
*/
|
|
293
|
+
async function runAssetsPull(options = {}) {
|
|
294
|
+
const cwd = options.cwd ?? process.cwd();
|
|
295
|
+
const link = await resolveLink(cwd);
|
|
296
|
+
if (!link) throw new Error("This project is not linked to a site you can reach — run `mechanica login` and `mechanica link <slug>`");
|
|
297
|
+
const dir = join(cwd, ASSETS_DIR);
|
|
298
|
+
const have = new Set(await localAssets(cwd));
|
|
299
|
+
const missing = (await listRemoteAssets(link)).filter((asset) => isAssetName(asset.name) && !have.has(asset.name));
|
|
300
|
+
if (!missing.length) {
|
|
301
|
+
console.info("Nothing to download — every upload of the site is already here");
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
await mkdir(dir, { recursive: true });
|
|
305
|
+
const info = {};
|
|
306
|
+
const done = [];
|
|
307
|
+
for (const asset of missing) {
|
|
308
|
+
const data = await downloadRemoteAsset(link, asset.name);
|
|
309
|
+
if (!data) continue;
|
|
310
|
+
await writeFile(join(dir, asset.name), data);
|
|
311
|
+
const entry = imageInfoOf(asset);
|
|
312
|
+
if (Object.keys(entry).length) info[asset.name] = entry;
|
|
313
|
+
done.push(asset.name);
|
|
314
|
+
console.info(`✓ ${asset.name}`);
|
|
315
|
+
}
|
|
316
|
+
updateImageManifest(join(cwd, ".mech"), info);
|
|
317
|
+
console.info(`Downloaded ${done.length} file(s) into ${ASSETS_DIR}`);
|
|
318
|
+
if (!await uploadsIgnored(cwd) && await stat(join(cwd, ".git")).catch(() => null)) console.info(`Note: ${ASSETS_DIR} is not ignored by git, so these files show as changes. Uploads live on the platform — add \`.mech/assets/\` and \`.mech/images.json\` to .gitignore.`);
|
|
319
|
+
return done;
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
export { git as a, PlatformError as c, request as d, requireToken as f, uploadsIgnored as i, REMOTE_NAME as l, saveToken as m, remoteAssetsFor as n, gitAuthEnv as o, hostOrigin as p, runAssetsPull as r, gitOk as s, pushUploads as t, parseRemoteUrl as u };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { n as configureSite, r as indexGeneratedPages } from "./site-
|
|
2
|
-
import { A as pageVersion, C as harvestPageImages, D as movePage, E as listPages, I as setPageDraft,
|
|
1
|
+
import { n as configureSite, r as indexGeneratedPages } from "./site-DfazSr3_.js";
|
|
2
|
+
import { A as pageVersion, C as harvestPageImages, D as movePage, E as listPages, I as setPageDraft, L as translationsOf, M as renamePage, N as savePage, Q as uploadResponse, R as contentFilesOf, T as listFolders, U as contentTypeOf, W as fsAssetStore, Y as storeUpload, _ as deletePage, a as folderOf, b as fillContentDefaults, c as mergeLocaleSiteData, f as readSiteData, g as createTranslation, h as createPage, j as readPage, l as mergeSiteData, m as PageExistsError, o as mergeFolderData, q as listUploads, s as mergeLocaleFolderData, u as readFolderData, v as deleteTranslation, w as imageNamesOf, x as fillImageMeta, y as duplicatePage } from "./rich-text-codec-BSD35k2U.js";
|
|
3
3
|
import fs, { readFileSync, readdirSync } from "node:fs";
|
|
4
|
-
import { basename,
|
|
4
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
5
5
|
import { EDITOR_DIST_DIR, mergeTranslation, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
|
|
6
6
|
import { parseComposedBlock, serializeComposedBlock } from "mechanica-shared/block-format";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
@@ -362,25 +362,6 @@ var json = (data, status = 200) => new Response(JSON.stringify(data), {
|
|
|
362
362
|
status,
|
|
363
363
|
headers: { "content-type": "application/json" }
|
|
364
364
|
});
|
|
365
|
-
/** Content types of the files a site stores — browsers don't sniff SVG or fonts. */
|
|
366
|
-
var MIME = {
|
|
367
|
-
".png": "image/png",
|
|
368
|
-
".jpg": "image/jpeg",
|
|
369
|
-
".jpeg": "image/jpeg",
|
|
370
|
-
".gif": "image/gif",
|
|
371
|
-
".webp": "image/webp",
|
|
372
|
-
".avif": "image/avif",
|
|
373
|
-
".svg": "image/svg+xml",
|
|
374
|
-
".ico": "image/x-icon",
|
|
375
|
-
".mp4": "video/mp4",
|
|
376
|
-
".webm": "video/webm",
|
|
377
|
-
".mp3": "audio/mpeg",
|
|
378
|
-
".pdf": "application/pdf",
|
|
379
|
-
".json": "application/json",
|
|
380
|
-
".txt": "text/plain; charset=utf-8",
|
|
381
|
-
".woff": "font/woff",
|
|
382
|
-
".woff2": "font/woff2"
|
|
383
|
-
};
|
|
384
365
|
/**
|
|
385
366
|
* Stream a file from `dir`, containing the resolved path so an encoded `..`
|
|
386
367
|
* (or an absolute path) cannot escape it.
|
|
@@ -395,7 +376,7 @@ function serveFile(dir, relative, headers = {}) {
|
|
|
395
376
|
const file = resolve(dir, decoded);
|
|
396
377
|
if (file !== dir && !file.startsWith(dir + sep)) return new Response("Forbidden", { status: 403 });
|
|
397
378
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return new Response("Not found", { status: 404 });
|
|
398
|
-
const type =
|
|
379
|
+
const type = contentTypeOf(file);
|
|
399
380
|
return new Response(Readable.toWeb(fs.createReadStream(file)), { headers: {
|
|
400
381
|
...type ? { "content-type": type } : {},
|
|
401
382
|
...headers
|
|
@@ -422,19 +403,7 @@ function validateComposed(def) {
|
|
|
422
403
|
function createEditorService(mech, options = {}) {
|
|
423
404
|
const mechDir = typeof mech === "string" ? mech : null;
|
|
424
405
|
const assets = options.assets ?? (mechDir ? fsAssetStore(mechDir) : null);
|
|
425
|
-
const asset =
|
|
426
|
-
let name;
|
|
427
|
-
try {
|
|
428
|
-
name = decodeURIComponent(encoded);
|
|
429
|
-
} catch {
|
|
430
|
-
return new Response("Bad request", { status: 400 });
|
|
431
|
-
}
|
|
432
|
-
if (!assets || !isAssetName(name)) return null;
|
|
433
|
-
const body = await assets.open(name);
|
|
434
|
-
if (!body) return null;
|
|
435
|
-
const type = MIME[extname(name).toLowerCase()];
|
|
436
|
-
return new Response(body, { headers: type ? { "content-type": type } : {} });
|
|
437
|
-
};
|
|
406
|
+
const asset = (encoded) => uploadResponse(assets, encoded);
|
|
438
407
|
let configured = null;
|
|
439
408
|
let generatedIndex = null;
|
|
440
409
|
const currentSite = async () => {
|
|
@@ -273,6 +273,143 @@ async function listUploads(assets) {
|
|
|
273
273
|
src: assetUrl(name)
|
|
274
274
|
}));
|
|
275
275
|
}
|
|
276
|
+
/** Content types of the files a site stores — browsers don't sniff SVG or fonts. */
|
|
277
|
+
var MIME = {
|
|
278
|
+
".png": "image/png",
|
|
279
|
+
".jpg": "image/jpeg",
|
|
280
|
+
".jpeg": "image/jpeg",
|
|
281
|
+
".gif": "image/gif",
|
|
282
|
+
".webp": "image/webp",
|
|
283
|
+
".avif": "image/avif",
|
|
284
|
+
".svg": "image/svg+xml",
|
|
285
|
+
".ico": "image/x-icon",
|
|
286
|
+
".mp4": "video/mp4",
|
|
287
|
+
".webm": "video/webm",
|
|
288
|
+
".mp3": "audio/mpeg",
|
|
289
|
+
".pdf": "application/pdf",
|
|
290
|
+
".json": "application/json",
|
|
291
|
+
".txt": "text/plain; charset=utf-8",
|
|
292
|
+
".woff": "font/woff",
|
|
293
|
+
".woff2": "font/woff2"
|
|
294
|
+
};
|
|
295
|
+
/** The content type a stored file is served with, when its extension says. */
|
|
296
|
+
var contentTypeOf = (fileName) => MIME[parse(fileName).ext.toLowerCase()];
|
|
297
|
+
/**
|
|
298
|
+
* The HTTP answer for an upload asked for by its address — the part of
|
|
299
|
+
* `/media/<name>` after the prefix, still percent-encoded. null when it is not
|
|
300
|
+
* an upload of this store, so the caller can fall through; a 400 for a name
|
|
301
|
+
* that is not valid encoding. One function for every host that serves uploads:
|
|
302
|
+
* the dev server, an editing host, a site's live host.
|
|
303
|
+
*/
|
|
304
|
+
async function uploadResponse(assets, encodedName) {
|
|
305
|
+
let name;
|
|
306
|
+
try {
|
|
307
|
+
name = decodeURIComponent(encodedName);
|
|
308
|
+
} catch {
|
|
309
|
+
return new Response("Bad request", { status: 400 });
|
|
310
|
+
}
|
|
311
|
+
if (!assets || !isAssetName(name)) return null;
|
|
312
|
+
const body = await assets.open(name);
|
|
313
|
+
if (!body) return null;
|
|
314
|
+
const type = contentTypeOf(name);
|
|
315
|
+
return new Response(body, { headers: type ? { "content-type": type } : {} });
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A store for rendering a site whose uploads the host keeps and serves itself.
|
|
319
|
+
* `hosted` is what the host knows about them — a name with its image info. The
|
|
320
|
+
* result knows those names (`list`, `info`) but cannot open them: an export
|
|
321
|
+
* over it fills in their dimensions and previews, copies no bytes, and reports
|
|
322
|
+
* them as the host's to serve (see `ExportResult.uploads`). Whatever the local
|
|
323
|
+
* store has is used as before.
|
|
324
|
+
*/
|
|
325
|
+
function withHostedAssets(local, hosted) {
|
|
326
|
+
const names = Object.keys(hosted).filter(isAssetName);
|
|
327
|
+
return {
|
|
328
|
+
write: (name, data) => local.write(name, data),
|
|
329
|
+
open: (name) => local.open(name),
|
|
330
|
+
saveInfo: (entries) => local.saveInfo(entries),
|
|
331
|
+
async list() {
|
|
332
|
+
return [.../* @__PURE__ */ new Set([...await local.list(), ...names])];
|
|
333
|
+
},
|
|
334
|
+
async info(wanted) {
|
|
335
|
+
const known = await local.info(wanted);
|
|
336
|
+
for (const name of wanted) {
|
|
337
|
+
const entry = hosted[name];
|
|
338
|
+
if (entry && !known[name]) known[name] = entry;
|
|
339
|
+
}
|
|
340
|
+
return known;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
var MISS_TTL_MS = 3e4;
|
|
345
|
+
var MEMORY_LIMIT = 67108864;
|
|
346
|
+
/**
|
|
347
|
+
* A store that falls back to {@link RemoteAssets} for what the local one lacks:
|
|
348
|
+
* an upload made in a hosted editor shows up in the dev server the first time a
|
|
349
|
+
* page asks for it, and (when `cache` allows) stays in the project's directory.
|
|
350
|
+
* Everything written goes to the local store; a remote failure is a miss, never
|
|
351
|
+
* an error — the dev server works offline with what it has.
|
|
352
|
+
*/
|
|
353
|
+
function withRemoteAssets(local, remote, options = {}) {
|
|
354
|
+
const fetched = /* @__PURE__ */ new Map();
|
|
355
|
+
let memory = 0;
|
|
356
|
+
const caching = async () => typeof options.cache === "function" ? await options.cache() : options.cache !== false;
|
|
357
|
+
const pull = (name) => {
|
|
358
|
+
let pending = fetched.get(name);
|
|
359
|
+
if (!pending) {
|
|
360
|
+
pending = (async () => {
|
|
361
|
+
const result = await remote.fetch(name).catch(() => null);
|
|
362
|
+
if (!result) {
|
|
363
|
+
setTimeout(() => fetched.delete(name), MISS_TTL_MS).unref?.();
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (await caching()) {
|
|
367
|
+
await local.write(name, result.data);
|
|
368
|
+
if (result.info && Object.keys(result.info).length) await local.saveInfo({ [name]: result.info });
|
|
369
|
+
fetched.delete(name);
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
memory += result.data.byteLength;
|
|
373
|
+
if (memory > MEMORY_LIMIT) {
|
|
374
|
+
memory -= result.data.byteLength;
|
|
375
|
+
fetched.delete(name);
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
})();
|
|
379
|
+
fetched.set(name, pending);
|
|
380
|
+
}
|
|
381
|
+
return pending;
|
|
382
|
+
};
|
|
383
|
+
let remoteNames = null;
|
|
384
|
+
const listRemote = () => {
|
|
385
|
+
if (!remoteNames || Date.now() - remoteNames.at > 3e4) remoteNames = {
|
|
386
|
+
at: Date.now(),
|
|
387
|
+
names: remote.list().catch(() => [])
|
|
388
|
+
};
|
|
389
|
+
return remoteNames.names;
|
|
390
|
+
};
|
|
391
|
+
return {
|
|
392
|
+
write: (name, data) => local.write(name, data),
|
|
393
|
+
saveInfo: (entries) => local.saveInfo(entries),
|
|
394
|
+
async open(name) {
|
|
395
|
+
return await local.open(name) ?? (await pull(name))?.data ?? null;
|
|
396
|
+
},
|
|
397
|
+
async list() {
|
|
398
|
+
return [.../* @__PURE__ */ new Set([...await local.list(), ...await listRemote()])];
|
|
399
|
+
},
|
|
400
|
+
async info(names) {
|
|
401
|
+
const known = await local.info(names);
|
|
402
|
+
const unknown = names.filter((name) => !known[name]);
|
|
403
|
+
if (!unknown.length) return known;
|
|
404
|
+
const here = new Set(await local.list());
|
|
405
|
+
await Promise.all(unknown.filter((name) => !here.has(name)).map(async (name) => {
|
|
406
|
+
const info = (await pull(name))?.info;
|
|
407
|
+
if (info && Object.keys(info).length) known[name] = info;
|
|
408
|
+
}));
|
|
409
|
+
return known;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
276
413
|
//#endregion
|
|
277
414
|
//#region src/server/content-files.ts
|
|
278
415
|
var slash = (path) => path.replace(/\\/g, "/");
|
|
@@ -1134,4 +1271,4 @@ function findRichTextProps(props) {
|
|
|
1134
1271
|
return found;
|
|
1135
1272
|
}
|
|
1136
1273
|
//#endregion
|
|
1137
|
-
export {
|
|
1274
|
+
export { withHostedAssets as $, pageVersion as A, memoryContentFiles as B, harvestPageImages as C, movePage as D, listPages as E, setPageCodec as F, isAssetName as G, assetFileOf as H, setPageDraft as I, readImageManifest as J, isDerivedAsset as K, translationsOf as L, renamePage as M, savePage as N, pageOfFile as O, setPageBlocks as P, uploadResponse as Q, contentFilesOf as R, getPagePath as S, listFolders as T, contentTypeOf as U, UPLOADS_PREFIX$1 as V, fsAssetStore as W, updateImageManifest as X, storeUpload as Y, uploadName as Z, deletePage as _, folderOf as a, fillContentDefaults as b, mergeLocaleSiteData as c, readFoldersData as d, withRemoteAssets as et, readSiteData as f, createTranslation as g, createPage as h, toBlockMeta as i, isRasterImage as it, readPage as j, pageUrlOf as k, mergeSiteData as l, PageExistsError as m, richTextCodecFromBlocks as n, analyzeImageBuffer as nt, mergeFolderData as o, readSiteLocaleOverride as p, listUploads as q, composedBlockMeta as r, hasSharp as rt, mergeLocaleFolderData as s, buildRichTextCodec as t, wasRecentlyMutated as tt, readFolderData as u, deleteTranslation as v, imageNamesOf as w, fillImageMeta as x, duplicatePage as y, fsContentFiles as z };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { F as setPageCodec, P as setPageBlocks, i as toBlockMeta, n as richTextCodecFromBlocks, r as composedBlockMeta } from "./rich-text-codec-
|
|
1
|
+
import { F as setPageCodec, P as setPageBlocks, i as toBlockMeta, n as richTextCodecFromBlocks, r as composedBlockMeta } from "./rich-text-codec-BSD35k2U.js";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { SITE_MANIFEST_FILE, areFieldSchemasRegistered, localePath, registerFieldSchemas } from "mechanica-shared";
|