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 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-DHrH4i9x.js";
2
- import { A as pageVersion, C as harvestPageImages, D as movePage, E as listPages, I as setPageDraft, J as storeUpload, K as listUploads, L as translationsOf, M as renamePage, N as savePage, R as contentFilesOf, T as listFolders, U as fsAssetStore, W as isAssetName, _ 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, s as mergeLocaleFolderData, u as readFolderData, v as deleteTranslation, w as imageNamesOf, x as fillImageMeta, y as duplicatePage } from "./rich-text-codec-DcgyZKq_.js";
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, extname, join, resolve, sep } from "node:path";
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 = MIME[extname(file).toLowerCase()];
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 = async (encoded) => {
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 { hasSharp as $, pageVersion as A, memoryContentFiles as B, harvestPageImages as C, movePage as D, listPages as E, setPageCodec as F, isDerivedAsset as G, assetFileOf as H, setPageDraft as I, storeUpload as J, listUploads as K, translationsOf as L, renamePage as M, savePage as N, pageOfFile as O, setPageBlocks as P, analyzeImageBuffer as Q, contentFilesOf as R, getPagePath as S, listFolders as T, fsAssetStore as U, UPLOADS_PREFIX$1 as V, isAssetName as W, uploadName as X, updateImageManifest as Y, wasRecentlyMutated as Z, deletePage as _, folderOf as a, fillContentDefaults as b, mergeLocaleSiteData as c, readFoldersData as d, isRasterImage as et, readSiteData as f, createTranslation as g, createPage as h, toBlockMeta as i, readPage as j, pageUrlOf as k, mergeSiteData as l, PageExistsError as m, richTextCodecFromBlocks as n, mergeFolderData as o, readSiteLocaleOverride as p, readImageManifest as q, composedBlockMeta as r, mergeLocaleFolderData as s, buildRichTextCodec as t, readFolderData as u, deleteTranslation as v, imageNamesOf as w, fillImageMeta as x, duplicatePage as y, fsContentFiles as z };
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-DcgyZKq_.js";
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";