@tokenoftrust/storefront-runner 2.4.27 → 2.4.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.27",
3
+ "version": "2.4.29",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -24,6 +24,18 @@ export const SPACES_DIR = "spaces";
24
24
  export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
25
25
  export const TOPOLOGY_VERSION = 1;
26
26
 
27
+ /**
28
+ * Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
29
+ * the ownership parity test holds the two equal).
30
+ */
31
+ export const PLATFORM_PATHS = Object.freeze([
32
+ "/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
33
+ "/apps", "/assets", "/auth", "/brand", "/capabilities", "/cockpit", "/dashboard", "/dev",
34
+ "/favicon.svg", "/health", "/js", "/feed.json", "/llms.txt", "/publication-discovery",
35
+ "/search-index.json", "/sitemap.xml", "/platform", "/preview", "/products", "/collections",
36
+ "/search", "/saved", "/robots.txt", "/shared", "/style-guide", "/tenants",
37
+ ]);
38
+
27
39
  const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
28
40
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
41
 
@@ -145,15 +157,17 @@ export function mountedSpaces(topology) {
145
157
  }
146
158
 
147
159
  /**
148
- * Which space owns a URL path: the longest canonical mount (or alias) that owns it, else the root.
149
- * Returns null for a path that cannot be read as a URL path.
160
+ * Which space owns a URL path: none for a platform path (`/api`, `/admin`, ...), else the longest
161
+ * canonical mount (or alias) that owns it, else the root. Null for a path that cannot be read as one.
150
162
  * @param {Topology} topology
151
163
  * @param {string} urlPath
152
- * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
164
+ * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | { space: null, via: "platform" } | null}
153
165
  */
154
166
  export function owningSpace(topology, urlPath) {
155
167
  const path = normalizeRequestPath(urlPath);
156
168
  if (path === null) return null;
169
+ const folded = path.toLowerCase();
170
+ if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))) return { space: null, via: "platform" };
157
171
  /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
158
172
  let best = null;
159
173
  for (const space of topology.spaces) {
@@ -221,3 +235,145 @@ export function excludeFromRootRepo(workspace, patterns) {
221
235
  if (!missing.length) return;
222
236
  appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
223
237
  }
238
+
239
+ /** The post collections a space can carry; each is served under its base path (default: its name). */
240
+ export const COLLECTIONS = ["blog", "the-build"];
241
+
242
+ /**
243
+ * The URL path a ROOT checkout file renders at, for the files whose URL the layout fixes:
244
+ * `content/pages-html/<slug>.html` and `content/pages/<slug>.json` at `/<slug>`, a collection's
245
+ * files under its base path. Null for files with no URL of their own (theme, chrome, assets).
246
+ * @param {TopologySpace} root
247
+ * @param {string} relPath path relative to the root checkout
248
+ */
249
+ export function rootFileUrlPath(root, relPath) {
250
+ const rel = String(relPath).replace(/\\/g, "/");
251
+ let m = /^content\/pages-html\/(.+)\.html$/.exec(rel) ?? /^content\/pages\/(.+)\.json$/.exec(rel);
252
+ if (m) return `/${m[1]}`;
253
+ m = /^content\/([^/]+)\//.exec(rel);
254
+ if (m && COLLECTIONS.includes(m[1])) {
255
+ const base = root.collections?.[m[1]] ?? m[1];
256
+ return base ? `/${base.replace(/^\/+|\/+$/g, "")}` : null;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Root files whose URL a mounted space owns. Visitors get the space's page at that URL, never the
263
+ * root's, so the file is dead weight in the root and a change to it ships nowhere.
264
+ * @param {Topology} topology
265
+ * @param {string[]} rootFiles paths relative to the root checkout
266
+ * @returns {Array<{ level: "warn", rule: string, file: string, message: string, fix: string }>}
267
+ */
268
+ export function rootFilesOwnedByMounts(topology, rootFiles) {
269
+ const root = rootSpace(topology);
270
+ const findings = [];
271
+ for (const file of rootFiles) {
272
+ const urlPath = rootFileUrlPath(root, file);
273
+ if (!urlPath) continue;
274
+ const owner = owningSpace(topology, urlPath);
275
+ if (!owner || !owner.space || owner.space.mount === "/") continue;
276
+ findings.push({
277
+ level: /** @type {"warn"} */ ("warn"),
278
+ rule: "publication-space-owns-url",
279
+ file,
280
+ message: `${urlPath} is served by the mounted space "${owner.space.id}" (${owner.space.source.forge}:${owner.space.source.repo}, mount ${owner.space.mount}), so this root file never reaches a visitor.`,
281
+ fix: `move it into ${spaceDir(owner.space)}/ (the "${owner.space.id}" repository), or delete it from the root`,
282
+ });
283
+ }
284
+ return findings;
285
+ }
286
+
287
+ /**
288
+ * Who owns a URL path. `spaceId` is null for a platform path, which no repository serves.
289
+ * @param {Topology} topology @param {string} urlPath
290
+ * @returns {{ path: string, spaceId: string|null, mount: string|null, via: string, repo: string|null, dir: string|null } | null}
291
+ */
292
+ export function describeOwner(topology, urlPath) {
293
+ const owner = owningSpace(topology, urlPath);
294
+ if (!owner) return null;
295
+ const { space, via } = owner;
296
+ if (!space) {
297
+ return { path: /** @type {string} */ (normalizeRequestPath(urlPath)), spaceId: null, mount: null, via, repo: null, dir: null };
298
+ }
299
+ return {
300
+ path: /** @type {string} */ (normalizeRequestPath(urlPath)),
301
+ spaceId: space.id,
302
+ mount: space.mount,
303
+ via,
304
+ repo: `${space.source.forge}:${space.source.repo}`,
305
+ dir: spaceDir(space) || ".",
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Route changed workspace files to the repository that ships them. Paths are relative to the
311
+ * root checkout (a space's files as `spaces/<id>/...`). Each file lands in its owning space's
312
+ * group; a root file whose URL a mounted space owns is ALSO named as misrouted (it ships with the
313
+ * root and still never reaches a visitor); a file under `spaces/` naming no declared space is
314
+ * unowned. Pure.
315
+ * @param {Topology} topology
316
+ * @param {string[]} files
317
+ * @returns {{ groups: Array<{ space: TopologySpace, files: string[] }>,
318
+ * misrouted: Array<{ file: string, owner: TopologySpace }>, unowned: string[] }}
319
+ */
320
+ export function routeWorkspaceChanges(topology, files) {
321
+ const root = rootSpace(topology);
322
+ /** @type {Map<string, string[]>} */
323
+ const byId = new Map(topology.spaces.map((space) => [space.id, []]));
324
+ const misrouted = [];
325
+ const unowned = [];
326
+ for (const file of files) {
327
+ const owner = spaceForWorkspaceFile(topology, file);
328
+ if (!owner) { unowned.push(file); continue; }
329
+ /** @type {string[]} */ (byId.get(owner.space.id)).push(file);
330
+ if (owner.space.mount === "/") {
331
+ const urlPath = rootFileUrlPath(root, file);
332
+ const served = urlPath ? owningSpace(topology, urlPath) : null;
333
+ if (served?.space && served.space.mount !== "/") misrouted.push({ file, owner: served.space });
334
+ }
335
+ }
336
+ return {
337
+ groups: topology.spaces.map((space) => ({ space, files: /** @type {string[]} */ (byId.get(space.id)) })),
338
+ misrouted,
339
+ unowned,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Parse `git status --porcelain=v1 -z` into changed paths (a rename yields its new path). Pure.
345
+ * @param {string} out
346
+ * @returns {string[]}
347
+ */
348
+ export function parsePorcelainZ(out) {
349
+ const parts = String(out).split("\0");
350
+ const paths = [];
351
+ for (let i = 0; i < parts.length; i++) {
352
+ const entry = parts[i];
353
+ if (!entry || entry.length < 4) continue;
354
+ const status = entry.slice(0, 2);
355
+ paths.push(entry.slice(3));
356
+ if (status[0] === "R" || status[0] === "C") i++; // the next field is the rename's source
357
+ }
358
+ return paths;
359
+ }
360
+
361
+ /**
362
+ * Every changed file in a workspace, relative to the root checkout: the root repository's own
363
+ * changes plus each cloned space's (`spaces/<id>/...`). `git(dir, args)` runs git in `dir`.
364
+ * @param {string} workspace
365
+ * @param {Topology} topology
366
+ * @param {(dir: string, args: string[]) => string} git
367
+ * @returns {string[]}
368
+ */
369
+ export function workspaceChangedFiles(workspace, topology, git) {
370
+ const files = parsePorcelainZ(git(workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]));
371
+ for (const space of mountedSpaces(topology)) {
372
+ const dir = join(workspace, spaceDir(space));
373
+ if (!existsSync(join(dir, ".git"))) continue;
374
+ for (const file of parsePorcelainZ(git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]))) {
375
+ files.push(`${spaceDir(space)}/${file}`);
376
+ }
377
+ }
378
+ return files;
379
+ }