mechanica 2.1.0-beta.4 → 2.1.0-beta.5

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 { W as isAssetName, Y as updateImageManifest, q as readImageManifest } from "./rich-text-codec-BXOKV94u.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,5 +1,5 @@
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-Co04D3rB.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-BXOKV94u.js";
3
3
  import fs, { readFileSync, readdirSync } from "node:fs";
4
4
  import { basename, extname, join, resolve, sep } from "node:path";
5
5
  import { EDITOR_DIST_DIR, mergeTranslation, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
@@ -273,6 +273,75 @@ async function listUploads(assets) {
273
273
  src: assetUrl(name)
274
274
  }));
275
275
  }
276
+ var MISS_TTL_MS = 3e4;
277
+ var MEMORY_LIMIT = 67108864;
278
+ /**
279
+ * A store that falls back to {@link RemoteAssets} for what the local one lacks:
280
+ * an upload made in a hosted editor shows up in the dev server the first time a
281
+ * page asks for it, and (when `cache` allows) stays in the project's directory.
282
+ * Everything written goes to the local store; a remote failure is a miss, never
283
+ * an error — the dev server works offline with what it has.
284
+ */
285
+ function withRemoteAssets(local, remote, options = {}) {
286
+ const fetched = /* @__PURE__ */ new Map();
287
+ let memory = 0;
288
+ const caching = async () => typeof options.cache === "function" ? await options.cache() : options.cache !== false;
289
+ const pull = (name) => {
290
+ let pending = fetched.get(name);
291
+ if (!pending) {
292
+ pending = (async () => {
293
+ const result = await remote.fetch(name).catch(() => null);
294
+ if (!result) {
295
+ setTimeout(() => fetched.delete(name), MISS_TTL_MS).unref?.();
296
+ return null;
297
+ }
298
+ if (await caching()) {
299
+ await local.write(name, result.data);
300
+ if (result.info && Object.keys(result.info).length) await local.saveInfo({ [name]: result.info });
301
+ fetched.delete(name);
302
+ return result;
303
+ }
304
+ memory += result.data.byteLength;
305
+ if (memory > MEMORY_LIMIT) {
306
+ memory -= result.data.byteLength;
307
+ fetched.delete(name);
308
+ }
309
+ return result;
310
+ })();
311
+ fetched.set(name, pending);
312
+ }
313
+ return pending;
314
+ };
315
+ let remoteNames = null;
316
+ const listRemote = () => {
317
+ if (!remoteNames || Date.now() - remoteNames.at > 3e4) remoteNames = {
318
+ at: Date.now(),
319
+ names: remote.list().catch(() => [])
320
+ };
321
+ return remoteNames.names;
322
+ };
323
+ return {
324
+ write: (name, data) => local.write(name, data),
325
+ saveInfo: (entries) => local.saveInfo(entries),
326
+ async open(name) {
327
+ return await local.open(name) ?? (await pull(name))?.data ?? null;
328
+ },
329
+ async list() {
330
+ return [.../* @__PURE__ */ new Set([...await local.list(), ...await listRemote()])];
331
+ },
332
+ async info(names) {
333
+ const known = await local.info(names);
334
+ const unknown = names.filter((name) => !known[name]);
335
+ if (!unknown.length) return known;
336
+ const here = new Set(await local.list());
337
+ await Promise.all(unknown.filter((name) => !here.has(name)).map(async (name) => {
338
+ const info = (await pull(name))?.info;
339
+ if (info && Object.keys(info).length) known[name] = info;
340
+ }));
341
+ return known;
342
+ }
343
+ };
344
+ }
276
345
  //#endregion
277
346
  //#region src/server/content-files.ts
278
347
  var slash = (path) => path.replace(/\\/g, "/");
@@ -1134,4 +1203,4 @@ function findRichTextProps(props) {
1134
1203
  return found;
1135
1204
  }
1136
1205
  //#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 };
1206
+ export { analyzeImageBuffer 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, wasRecentlyMutated 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, withRemoteAssets as Z, deletePage as _, folderOf as a, fillContentDefaults as b, mergeLocaleSiteData as c, readFoldersData as d, hasSharp 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, isRasterImage 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-BXOKV94u.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";
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
- import { t as buildSiteManifest } from "./chunks/site-DHrH4i9x.js";
2
- import { $ as hasSharp, G as isDerivedAsset, Q as analyzeImageBuffer, Y as updateImageManifest, et as isRasterImage, q as readImageManifest } from "./chunks/rich-text-codec-DcgyZKq_.js";
1
+ import { t as buildSiteManifest } from "./chunks/site-Co04D3rB.js";
2
+ import { $ as analyzeImageBuffer, G as isDerivedAsset, Y as updateImageManifest, et as hasSharp, q as readImageManifest, tt as isRasterImage } from "./chunks/rich-text-codec-BXOKV94u.js";
3
+ import { a as git, c as PlatformError, d as request, f as requireToken, l as REMOTE_NAME, m as saveToken, o as gitAuthEnv, p as hostOrigin, r as runAssetsPull, s as gitOk, t as pushUploads, u as parseRemoteUrl } from "./chunks/assets-C7yHGJBU.js";
3
4
  import { exportBuilt } from "./export.js";
4
5
  import fs, { readFileSync } from "node:fs";
5
6
  import { dirname, join } from "node:path";
6
7
  import { EDITOR_DIST_DIR, LEGACY_UPLOADS_PREFIX, SITE_MANIFEST_FILE, UPLOADS_PREFIX } from "mechanica-shared";
7
- import { chmod, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
8
- import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { build } from "vite";
8
+ import { appendFile, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
10
9
  import os from "node:os";
11
10
  import { spawn } from "node:child_process";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { build } from "vite";
12
13
  import readline from "node:readline";
13
14
  //#region src/cli/args.ts
14
15
  /** Parse `argv` (without node/script) into a command, positionals and a `--flag[=value]` map. */
@@ -214,153 +215,6 @@ async function runMigrate(options = {}) {
214
215
  console.info(changed.length ? `Updated ${changed.length} file(s): uploads are now referenced as ${UPLOADS_PREFIX}<file>` : "Nothing to migrate — the content is already in the current format");
215
216
  return changed;
216
217
  }
217
- /** Normalize a host argument to an origin (`https://example.com`, no trailing slash or path). */
218
- function hostOrigin(host) {
219
- const value = host ?? process.env.MECHANICA_HOST ?? "https://mechanica.jt3.ru";
220
- return new URL(/^https?:\/\//.test(value) ? value : `https://${value}`).origin;
221
- }
222
- /** Where access tokens are kept: one file per user, outside any project. */
223
- function credentialsFile() {
224
- return join(process.env.MECHANICA_CONFIG_DIR ?? join(os.homedir(), ".mechanica"), "credentials.json");
225
- }
226
- async function readAll() {
227
- try {
228
- const parsed = JSON.parse(await readFile(credentialsFile(), "utf-8"));
229
- return parsed && typeof parsed === "object" ? parsed : {};
230
- } catch {
231
- return {};
232
- }
233
- }
234
- /**
235
- * The access token for a platform origin: `MECHANICA_TOKEN` (CI) wins over the
236
- * one `mechanica login` saved.
237
- */
238
- async function getToken(origin) {
239
- if (process.env.MECHANICA_TOKEN) return process.env.MECHANICA_TOKEN;
240
- return (await readAll())[origin] ?? null;
241
- }
242
- /** Remember the token for an origin, in a file only the user can read. */
243
- async function saveToken(origin, token) {
244
- const file = credentialsFile();
245
- const all = await readAll();
246
- all[origin] = token;
247
- await mkdir(join(file, ".."), { recursive: true });
248
- await writeFile(file, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
249
- await chmod(file, 384).catch(() => {});
250
- return file;
251
- }
252
- //#endregion
253
- //#region src/cli/platform/api.ts
254
- /** A failed platform request: the HTTP status plus the message the platform gave. */
255
- var PlatformError = class extends Error {
256
- status;
257
- constructor(status, message) {
258
- super(message);
259
- this.status = status;
260
- }
261
- };
262
- /** The platform answers errors as plain text or as `{ error: { field: { message } } }`. */
263
- function errorMessage(status, text) {
264
- try {
265
- const error = JSON.parse(text)?.error;
266
- if (typeof error === "string") return error;
267
- if (error && typeof error === "object") {
268
- const messages = Object.values(error).map((item) => item?.message).filter((message) => typeof message === "string");
269
- if (messages.length) return messages.join("; ");
270
- }
271
- } catch {}
272
- return text.trim() || `Request failed (${status})`;
273
- }
274
- /** One request to the platform API; resolves with the parsed JSON body (or null). */
275
- async function request(origin, method, path, options = {}) {
276
- const headers = {};
277
- if (options.token) headers.authorization = `Bearer ${options.token}`;
278
- if (options.raw) headers["content-type"] = "application/octet-stream";
279
- else if (options.body !== void 0) headers["content-type"] = "application/json";
280
- let response;
281
- try {
282
- response = await fetch(origin + path, {
283
- method,
284
- headers,
285
- body: options.raw ?? (options.body !== void 0 ? JSON.stringify(options.body) : void 0)
286
- });
287
- } catch (error) {
288
- throw new PlatformError(0, `Could not reach ${origin} (${error.message})`);
289
- }
290
- const text = await response.text();
291
- if (!response.ok) throw new PlatformError(response.status, errorMessage(response.status, text));
292
- return text ? JSON.parse(text) : null;
293
- }
294
- /** The saved token for `origin`, or an error telling the user to log in. */
295
- async function requireToken(origin) {
296
- const token = await getToken(origin);
297
- if (!token) throw new Error(`Not logged in to ${origin} — run \`mechanica login\` first`);
298
- return token;
299
- }
300
- /** The name of the git remote `mechanica link` manages. */
301
- var REMOTE_NAME = "mechanica";
302
- /**
303
- * Split a site's git remote URL (`https://host/git/<slug>.git`) into the
304
- * platform origin and the slug; null for a URL that is not one of ours.
305
- */
306
- function parseRemoteUrl(url) {
307
- try {
308
- const parsed = new URL(url);
309
- const match = /^\/git\/([a-z0-9][a-z0-9-]*[a-z0-9])\.git\/?$/.exec(parsed.pathname);
310
- if (!match || !/^https?:$/.test(parsed.protocol)) return null;
311
- return {
312
- origin: parsed.origin,
313
- slug: match[1]
314
- };
315
- } catch {
316
- return null;
317
- }
318
- }
319
- //#endregion
320
- //#region src/cli/platform/git.ts
321
- /**
322
- * Environment that makes git send the access token to one origin only, as HTTP
323
- * Basic credentials. Passed through `GIT_CONFIG_*` variables so the token never
324
- * appears on a command line (visible to other processes) or in the repo config.
325
- */
326
- function gitAuthEnv(origin, token) {
327
- const basic = Buffer.from(`mechanica:${token}`).toString("base64");
328
- return {
329
- GIT_CONFIG_COUNT: "1",
330
- GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
331
- GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
332
- GIT_TERMINAL_PROMPT: "0"
333
- };
334
- }
335
- /** Run git in `cwd` and collect its output; never throws on a non-zero exit. */
336
- function git(cwd, args, env = {}) {
337
- return new Promise((resolve, reject) => {
338
- const child = spawn("git", args, {
339
- cwd,
340
- env: {
341
- ...process.env,
342
- ...env
343
- },
344
- windowsHide: true
345
- });
346
- let stdout = "";
347
- let stderr = "";
348
- child.stdout.on("data", (chunk) => stdout += chunk);
349
- child.stderr.on("data", (chunk) => stderr += chunk);
350
- child.on("error", (error) => reject(error.code === "ENOENT" ? /* @__PURE__ */ new Error("git is not installed (or not on PATH)") : error));
351
- child.on("close", (code) => resolve({
352
- code: code ?? 1,
353
- stdout: stdout.trim(),
354
- stderr: stderr.trim()
355
- }));
356
- });
357
- }
358
- /** Like {@link git}, but a non-zero exit throws with git's own message. */
359
- async function gitOk(cwd, args, env = {}) {
360
- const result = await git(cwd, args, env);
361
- if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git ${args[0]} failed`);
362
- return result.stdout;
363
- }
364
218
  //#endregion
365
219
  //#region src/cli/platform/link.ts
366
220
  /**
@@ -413,8 +267,36 @@ async function runLink(slug, options) {
413
267
  site.gitUrl
414
268
  ]);
415
269
  console.info(`Linked to ${site.slug} (${site.gitUrl})`);
270
+ await ignoreUploads(cwd);
416
271
  console.info("Commit your work, then run `mechanica push` to deploy it.");
417
272
  }
273
+ /** What a linked project keeps out of git: uploads live on the platform. */
274
+ var UPLOAD_IGNORES = [".mech/assets/", ".mech/images.json"];
275
+ /**
276
+ * A linked project's uploads live on the platform, not in git: `push` sends
277
+ * the files, the dev server fetches the ones made online. Make git ignore the
278
+ * local copies, so a fetched file is never an uncommitted change. Uploads that
279
+ * are already committed stay committed (git keeps tracking them) and keep
280
+ * working — the hint says how to move them over.
281
+ */
282
+ async function ignoreUploads(cwd) {
283
+ const file = join(cwd, ".gitignore");
284
+ const current = await readFile(file, "utf-8").catch(() => "");
285
+ const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
286
+ const missing = UPLOAD_IGNORES.filter((entry) => !lines.has(entry) && !lines.has(entry.replace(/\/$/, "")));
287
+ if (missing.length) {
288
+ const block = `${current && !current.endsWith("\n") ? "\n" : ""}\n# Uploads live on the platform (mechanica push sends them, the dev server fetches them)\n${missing.join("\n")}\n`;
289
+ await appendFile(file, block);
290
+ console.info(`Added ${missing.join(" and ")} to .gitignore — uploads are kept by the platform, not by git.`);
291
+ }
292
+ const tracked = await git(cwd, [
293
+ "ls-files",
294
+ "--",
295
+ ".mech/assets",
296
+ ".mech/images.json"
297
+ ]);
298
+ if (tracked.code === 0 && tracked.stdout) console.info("Uploads already committed in this repository keep working. To move them to the platform:\n git rm -r --cached .mech/assets .mech/images.json && git commit -m \"Uploads live on the platform\"");
299
+ }
418
300
  //#endregion
419
301
  //#region src/cli/platform/login.ts
420
302
  /** Ask one line on the terminal; with `hidden`, the typed characters are not echoed. */
@@ -451,11 +333,14 @@ async function runLogin(options) {
451
333
  if (!token) {
452
334
  if (!process.stdin.isTTY) throw new Error("No terminal to ask for credentials — pass --token <access token>");
453
335
  console.info(`Signing in to ${origin}`);
336
+ const login = await prompt("Email: ");
337
+ const password = await prompt("Password: ", true);
338
+ const session = await request(origin, "POST", "/api/account/login", { body: {
339
+ login,
340
+ password
341
+ } });
454
342
  token = (await request(origin, "POST", "/api/account/tokens", {
455
- token: (await request(origin, "POST", "/api/account/login", { body: {
456
- login: await prompt("Email: "),
457
- password: await prompt("Password: ", true)
458
- } })).accessToken,
343
+ token: session.accessToken,
459
344
  body: { name: `CLI on ${os.hostname()}` }
460
345
  })).token;
461
346
  }
@@ -528,7 +413,8 @@ var AUTO_DEPLOY_WAIT_MS = 1e4;
528
413
  * `mechanica push`: publish the committed project to the platform.
529
414
  *
530
415
  * 1. bring in what was published online since the last pull (`pull --rebase`),
531
- * 2. `git push` the commits to the site's repository,
416
+ * 2. send the uploads the platform lacks (they are not in git), then `git push`
417
+ * the commits to the site's repository,
532
418
  * 3. if the code changed, build locally and upload the bundle for that commit
533
419
  * (content-only pushes reuse the bundle already there — no build at all),
534
420
  * 4. wait for the deploy and report it.
@@ -574,6 +460,11 @@ async function runPush() {
574
460
  ], auth);
575
461
  if (pulled.code !== 0) throw new Error(`Could not rebase onto the platform's changes:\n${pulled.stderr || pulled.stdout}\nResolve the conflict (\`git rebase --continue\`), then run \`mechanica push\` again.`);
576
462
  }
463
+ await pushUploads(cwd, {
464
+ ...link,
465
+ uuid: site.uuid,
466
+ token
467
+ });
577
468
  const head = await gitOk(cwd, ["rev-parse", "HEAD"]);
578
469
  console.info(`Pushing ${head.slice(0, 8)} to ${link.slug}…`);
579
470
  const pushed = await git(cwd, [
@@ -1324,6 +1215,10 @@ Usage:
1324
1215
  previews) for .mech/assets into .mech/images.json —
1325
1216
  for pages authored without the browser editor
1326
1217
  (needs the optional sharp dependency; --force redoes all)
1218
+ mechanica assets pull Download the site's uploads this project lacks into
1219
+ .mech/assets (the dev server fetches them one by one
1220
+ on demand; this gets all of them — for working offline
1221
+ or a local export)
1327
1222
  mechanica migrate Bring .mech content up to the current format: uploads
1328
1223
  are referenced as /media/<file> (was /@mechanica/assets/)
1329
1224
  `;
@@ -1339,6 +1234,10 @@ async function run(argv) {
1339
1234
  assetsUrl: str(flags["assets-url"])
1340
1235
  });
1341
1236
  case "images": return runImages({ force: flags.force === true });
1237
+ case "assets":
1238
+ if (args[0] !== "pull") throw new Error("Usage: mechanica assets pull");
1239
+ await runAssetsPull();
1240
+ return;
1342
1241
  case "migrate":
1343
1242
  await runMigrate();
1344
1243
  return;
package/dist/export.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as hasSharp, E as listPages, F as setPageCodec, G as isDerivedAsset, H as assetFileOf, Q as analyzeImageBuffer, S as getPagePath, V as UPLOADS_PREFIX$1, Y as updateImageManifest, d as readFoldersData, f as readSiteData, i as toBlockMeta, p as readSiteLocaleOverride, q as readImageManifest, r as composedBlockMeta, t as buildRichTextCodec, u as readFolderData } from "./chunks/rich-text-codec-DcgyZKq_.js";
1
+ import { $ as analyzeImageBuffer, E as listPages, F as setPageCodec, G as isDerivedAsset, H as assetFileOf, S as getPagePath, V as UPLOADS_PREFIX$1, Y as updateImageManifest, d as readFoldersData, et as hasSharp, f as readSiteData, i as toBlockMeta, p as readSiteLocaleOverride, q as readImageManifest, r as composedBlockMeta, t as buildRichTextCodec, u as readFolderData } from "./chunks/rich-text-codec-BXOKV94u.js";
2
2
  import { n as blockAssetLinks, t as BLOCKS_MANIFEST_FILE } from "./chunks/page-assets-DsJXmoDs.js";
3
3
  import { dirname, join, parse, relative, resolve } from "node:path";
4
4
  import { EDITOR_DIST_DIR, SITE_MANIFEST_FILE, applySeoTags, auditPageHtml, buildRobotsTxt, buildSitemap, findUnknownBlocks, generateProject, isPaginatedQuery, localePath, mergeTranslation, migrateContent, paginationVariantPath, registerFieldSchemas, resolveQueryKey, templateBlockIds, validateLinks, walkSchema, walkTree } from "mechanica-shared";
package/dist/plugin.js CHANGED
@@ -1,6 +1,7 @@
1
- import { a as collectGeneratedPages, n as configureSite, r as indexGeneratedPages, t as buildSiteManifest } from "./chunks/site-DHrH4i9x.js";
2
- import { a as renderEditablePage, c as fillStateImages, d as loadComposedDefinitions, n as toNodeMiddleware, o as buildGeneratedState, r as createEditorService, s as buildPageState, t as toAssetMiddleware, u as collectComposed } from "./chunks/node-adapter-BJbGKMgx.js";
3
- import { U as fsAssetStore, Z as wasRecentlyMutated, k as pageUrlOf } from "./chunks/rich-text-codec-DcgyZKq_.js";
1
+ import { a as collectGeneratedPages, n as configureSite, r as indexGeneratedPages, t as buildSiteManifest } from "./chunks/site-Co04D3rB.js";
2
+ import { a as renderEditablePage, c as fillStateImages, d as loadComposedDefinitions, n as toNodeMiddleware, o as buildGeneratedState, r as createEditorService, s as buildPageState, t as toAssetMiddleware, u as collectComposed } from "./chunks/node-adapter-GZZrfGSz.js";
3
+ import { Q as wasRecentlyMutated, U as fsAssetStore, Z as withRemoteAssets, k as pageUrlOf } from "./chunks/rich-text-codec-BXOKV94u.js";
4
+ import { i as uploadsIgnored, n as remoteAssetsFor } from "./chunks/assets-C7yHGJBU.js";
4
5
  import { t as BLOCKS_MANIFEST_FILE } from "./chunks/page-assets-DsJXmoDs.js";
5
6
  import { existsSync, readFileSync, readdirSync } from "node:fs";
6
7
  import { join, relative } from "node:path";
@@ -955,6 +956,12 @@ function mechanica(options = {}) {
955
956
  let blocksDir = "";
956
957
  let widgetsDir = "";
957
958
  let mechDir = "";
959
+ let uploadStore = null;
960
+ let uploadsCached = null;
961
+ const uploads = () => uploadStore ??= withRemoteAssets(fsAssetStore(mechDir), remoteAssetsFor(root), { cache: () => uploadsCached ??= uploadsIgnored(root).then((ignored) => {
962
+ if (!ignored) console.info("[mechanica] Uploads made online are fetched from the platform on every start: add `.mech/assets/` and `.mech/images.json` to .gitignore (`mechanica link` does it) to keep them locally.");
963
+ return ignored;
964
+ }) });
958
965
  let composedDir = "";
959
966
  let composerFilePath = "";
960
967
  let composerSpecifier = "";
@@ -1231,7 +1238,10 @@ function mechanica(options = {}) {
1231
1238
  configureServer(server) {
1232
1239
  server.middlewares.use("/@mechanica/preview", createPreviewMiddleware());
1233
1240
  server.middlewares.use("/@mechanica/composer", createComposerMiddleware());
1234
- const service = createEditorService(mechDir, { site: () => loadSite(server) });
1241
+ const service = createEditorService(mechDir, {
1242
+ site: () => loadSite(server),
1243
+ assets: uploads()
1244
+ });
1235
1245
  server.middlewares.use("/@mechanica", toNodeMiddleware(service));
1236
1246
  server.middlewares.use(UPLOADS_PREFIX.replace(/\/$/, ""), toAssetMiddleware(service));
1237
1247
  if (server.httpServer && !server.httpServer.listening) server.httpServer.once("listening", () => void loadSite(server));
@@ -1307,7 +1317,7 @@ function mechanica(options = {}) {
1307
1317
  const withEditor = !url.searchParams.has("mechanica-shot");
1308
1318
  if (ctx.server) await loadSite(ctx.server);
1309
1319
  const generated = (await loadGeneratedIndex()).byServed.get(urlPath);
1310
- const state = await fillStateImages(mechDir, fsAssetStore(mechDir), generated ? buildGeneratedState(mechDir, generated, locales) : buildPageState(mechDir, urlPath, locales));
1320
+ const state = await fillStateImages(mechDir, uploads(), generated ? buildGeneratedState(mechDir, generated, locales) : buildPageState(mechDir, urlPath, locales));
1311
1321
  return renderEditablePage(html, state, {
1312
1322
  site: {
1313
1323
  url: options.siteUrl,
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
- import { i as readSiteManifest, n as configureSite, t as buildSiteManifest } from "./chunks/site-DHrH4i9x.js";
2
- import { a as renderEditablePage, i as readEditorTemplate, n as toNodeMiddleware, r as createEditorService, t as toAssetMiddleware } from "./chunks/node-adapter-BJbGKMgx.js";
3
- import { B as memoryContentFiles, G as isDerivedAsset, O as pageOfFile, U as fsAssetStore, X as uploadName, z as fsContentFiles } from "./chunks/rich-text-codec-DcgyZKq_.js";
1
+ import { i as readSiteManifest, n as configureSite, t as buildSiteManifest } from "./chunks/site-Co04D3rB.js";
2
+ import { a as renderEditablePage, i as readEditorTemplate, n as toNodeMiddleware, r as createEditorService, t as toAssetMiddleware } from "./chunks/node-adapter-GZZrfGSz.js";
3
+ import { B as memoryContentFiles, G as isDerivedAsset, O as pageOfFile, U as fsAssetStore, X as uploadName, Z as withRemoteAssets, z as fsContentFiles } from "./chunks/rich-text-codec-BXOKV94u.js";
4
4
  import { EDITOR_DIST_DIR, SITE_MANIFEST_FILE, UPLOADS_PREFIX, uploadNameOf } from "mechanica-shared";
5
- export { EDITOR_DIST_DIR, SITE_MANIFEST_FILE, UPLOADS_PREFIX, buildSiteManifest, configureSite, createEditorService, fsAssetStore, fsContentFiles, isDerivedAsset, memoryContentFiles, pageOfFile, readEditorTemplate, readSiteManifest, renderEditablePage, toAssetMiddleware, toNodeMiddleware, uploadName, uploadNameOf };
5
+ export { EDITOR_DIST_DIR, SITE_MANIFEST_FILE, UPLOADS_PREFIX, buildSiteManifest, configureSite, createEditorService, fsAssetStore, fsContentFiles, isDerivedAsset, memoryContentFiles, pageOfFile, readEditorTemplate, readSiteManifest, renderEditablePage, toAssetMiddleware, toNodeMiddleware, uploadName, uploadNameOf, withRemoteAssets };
@@ -0,0 +1,51 @@
1
+ import { type ImageManifestEntry, type RemoteAssets } from '../../server/assets-store';
2
+ /** A site this project can talk to: where it is, and as whom. */
3
+ export interface SiteLink {
4
+ origin: string;
5
+ slug: string;
6
+ uuid: string;
7
+ token: string;
8
+ }
9
+ /** An upload as the platform lists it. */
10
+ export interface RemoteAsset extends ImageManifestEntry {
11
+ name: string;
12
+ size: number;
13
+ hash: string;
14
+ }
15
+ /**
16
+ * The site this project is linked to (the `mechanica` git remote) with the
17
+ * saved token for it — or null when it is not linked, not logged in, or the
18
+ * account has no access to the site. Never throws: an unlinked project is the
19
+ * normal case for the dev server.
20
+ */
21
+ export declare function resolveLink(cwd: string): Promise<SiteLink | null>;
22
+ /** The uploads the platform's store holds for the site. */
23
+ export declare const listRemoteAssets: (link: SiteLink) => Promise<RemoteAsset[]>;
24
+ /** One upload's bytes from the platform, or null when it has none of that name. */
25
+ export declare function downloadRemoteAsset(link: SiteLink, name: string): Promise<Uint8Array | null>;
26
+ /**
27
+ * Send the platform the uploads this project has and it lacks — the files in
28
+ * `.mech/assets` that are not committed (committed ones travel with git) — with
29
+ * what `.mech/images.json` knows about them. Returns the names sent.
30
+ */
31
+ export declare function pushUploads(cwd: string, link: SiteLink, log?: (message: string) => void): Promise<string[]>;
32
+ /**
33
+ * Whether git ignores the project's uploads directory — i.e. whether a file
34
+ * fetched into it stays out of `git status`. Decides if the dev server may
35
+ * keep what it fetches (see `withRemoteAssets`).
36
+ */
37
+ export declare function uploadsIgnored(cwd: string): Promise<boolean>;
38
+ /**
39
+ * The platform's uploads as the dev server sees them: resolved lazily, once —
40
+ * a project that is not linked (or is offline) simply has none.
41
+ */
42
+ export declare function remoteAssetsFor(cwd: string): RemoteAssets;
43
+ /**
44
+ * `mechanica assets pull` — download every upload the platform has and this
45
+ * project lacks into `.mech/assets`, with its image info. What the dev server
46
+ * does one file at a time, for all of them: before an offline session, a local
47
+ * `mechanica export`, or leaving the platform. Returns the names downloaded.
48
+ */
49
+ export declare function runAssetsPull(options?: {
50
+ cwd?: string;
51
+ }): Promise<string[]>;
@@ -9,3 +9,11 @@ export declare function runLink(slug: string | undefined, options: {
9
9
  create?: boolean;
10
10
  name?: string;
11
11
  }): Promise<void>;
12
+ /**
13
+ * A linked project's uploads live on the platform, not in git: `push` sends
14
+ * the files, the dev server fetches the ones made online. Make git ignore the
15
+ * local copies, so a fetched file is never an uncommitted change. Uploads that
16
+ * are already committed stay committed (git keeps tracking them) and keep
17
+ * working — the hint says how to move them over.
18
+ */
19
+ export declare function ignoreUploads(cwd: string): Promise<void>;
@@ -2,7 +2,8 @@
2
2
  * `mechanica push`: publish the committed project to the platform.
3
3
  *
4
4
  * 1. bring in what was published online since the last pull (`pull --rebase`),
5
- * 2. `git push` the commits to the site's repository,
5
+ * 2. send the uploads the platform lacks (they are not in git), then `git push`
6
+ * the commits to the site's repository,
6
7
  * 3. if the code changed, build locally and upload the bundle for that commit
7
8
  * (content-only pushes reuse the bundle already there — no build at all),
8
9
  * 4. wait for the deploy and report it.
@@ -84,3 +84,34 @@ export declare function listUploads(assets: AssetStore): Promise<{
84
84
  name: string;
85
85
  src: string;
86
86
  }[]>;
87
+ /**
88
+ * Where a project's uploads also live — a hosted platform the project is linked
89
+ * to. Uploads made in its online editor are not in the project's directory
90
+ * until something asks for them.
91
+ */
92
+ export interface RemoteAssets {
93
+ /** An upload's bytes with what is known about it as an image; null when there is no such upload. */
94
+ fetch(name: string): Promise<{
95
+ data: Uint8Array;
96
+ info?: ImageManifestEntry;
97
+ } | null>;
98
+ /** The names of the uploads kept there. */
99
+ list(): Promise<string[]>;
100
+ }
101
+ export interface RemoteAssetsOptions {
102
+ /**
103
+ * Whether a fetched upload is written into the local store. When it is not,
104
+ * the upload is fetched once per process and served from memory — for a
105
+ * project whose uploads directory is under version control, where a file
106
+ * appearing in it would be an uncommitted change.
107
+ */
108
+ cache?: boolean | (() => boolean | Promise<boolean>);
109
+ }
110
+ /**
111
+ * A store that falls back to {@link RemoteAssets} for what the local one lacks:
112
+ * an upload made in a hosted editor shows up in the dev server the first time a
113
+ * page asks for it, and (when `cache` allows) stays in the project's directory.
114
+ * Everything written goes to the local store; a remote failure is a miss, never
115
+ * an error — the dev server works offline with what it has.
116
+ */
117
+ export declare function withRemoteAssets(local: AssetStore, remote: RemoteAssets, options?: RemoteAssetsOptions): AssetStore;
@@ -8,7 +8,7 @@ export { createEditorService, type EditorService, type EditorServiceOptions, typ
8
8
  export { toNodeMiddleware, toAssetMiddleware, type NodeMiddleware } from './node-adapter';
9
9
  export { fsContentFiles, memoryContentFiles, type ContentFiles, type ContentChanges, type MemoryContentFiles, type Mech, } from './content-files';
10
10
  export { pageOfFile } from './pages-store';
11
- export { fsAssetStore, uploadName, isDerivedAsset, type AssetStore, type ImageManifest, type ImageManifestEntry, } from './assets-store';
11
+ export { fsAssetStore, withRemoteAssets, uploadName, isDerivedAsset, type AssetStore, type RemoteAssets, type RemoteAssetsOptions, type ImageManifest, type ImageManifestEntry, } from './assets-store';
12
12
  export { buildSiteManifest, readSiteManifest, configureSite, type SiteManifestInput } from './site';
13
13
  export { renderEditablePage, readEditorTemplate, type EditablePageOptions } from './editor-page';
14
14
  export { SITE_MANIFEST_FILE, EDITOR_DIST_DIR, UPLOADS_PREFIX, uploadNameOf, type SiteManifest, type EditorHostConfig, } from 'mechanica-shared';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica",
3
- "version": "2.1.0-beta.4",
3
+ "version": "2.1.0-beta.5",
4
4
  "description": "Build Vue 3 websites with a visual block editor",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -66,7 +66,7 @@
66
66
  "prepublishOnly": "bun ../../scripts/ensure-bun-publish.js && bun run build"
67
67
  },
68
68
  "dependencies": {
69
- "mechanica-shared": "2.1.0-beta.4",
69
+ "mechanica-shared": "2.1.0-beta.5",
70
70
  "@vue/compiler-sfc": "^3.5.38",
71
71
  "compact-json-schema": "^0.1.5",
72
72
  "estree-walker": "^3.0.3",