From e66c6b81e74b198ffb0e4f34abe519eb8518bbde Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 5 Oct 2026 13:58:42 +0200 Subject: [PATCH 1/2] refactor(content): move the moon catalogue reader out of build preparation --- site/moon-catalogue.mts | 23 +++++++++++++++++++++++ site/prepare-body-moons.mts | 23 ++--------------------- 2 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 site/moon-catalogue.mts diff --git a/site/moon-catalogue.mts b/site/moon-catalogue.mts new file mode 100644 index 00000000000..111d3190d1f --- /dev/null +++ b/site/moon-catalogue.mts @@ -0,0 +1,23 @@ +import moonCatalogues from './source/moon-catalogues.json' with { type: 'json' }; +import { sourceArray, sourceId, sourceObject, sourceText, sourceUnique } from '@cssearth/objects/sources'; + +/** Read names from the source catalogue without creating scene objects or invented positions. */ +export function parseMoonCatalogue(input: unknown) { + const catalogue = sourceObject(input); + const moons = sourceArray(catalogue.moons, value => { + const moon = sourceObject(value); + return { id: sourceId(moon.id), name: sourceText(moon.name) }; + }); + sourceUnique(moons.map(moon => moon.id), 'moon identities'); + const count = catalogue.count; + if (!Number.isInteger(count) || count !== moons.length) throw new TypeError('Moon catalogue count does not match its entries.'); + return { moons }; +} + +const catalogues: Readonly>> = Object.fromEntries( + moonCatalogues.systems.map(system => [sourceId(system.id), parseMoonCatalogue(system)])); + +/** The moons a host's source catalogue names, in the catalogue's order; none for a host without one. */ +export const catalogueMoons = (hostId: string): readonly { readonly id: string; readonly name: string }[] => catalogues[hostId]?.moons ?? []; + +export { catalogues }; diff --git a/site/prepare-body-moons.mts b/site/prepare-body-moons.mts index fc939f68a37..a24c5963af7 100644 --- a/site/prepare-body-moons.mts +++ b/site/prepare-body-moons.mts @@ -1,8 +1,8 @@ import { SEARCH_OBJECTS } from './search/search-objects.mts'; import type { ObjectEntry } from './objects.mts'; import { systemObjectId } from './navigation/system-address.mts'; -import moonCatalogues from './source/moon-catalogues.json' with { type: 'json' }; -import { sourceArray, sourceId, sourceObject, sourceText, sourceUnique } from '@cssearth/objects/sources'; +import { catalogues } from './moon-catalogue.mts'; +export { parseMoonCatalogue, catalogueMoons } from './moon-catalogue.mts'; import { labelEligible } from '@cssearth/renderer/labels/universe-label-policy.ts'; export interface MoonListEntry { id: string; name: string; object?: ObjectEntry; } @@ -13,25 +13,6 @@ export function hasProperMoonName(moon: { name: string; provisionalDesignation: return labelEligible({ named: moon.name !== moon.provisionalDesignation }); } -/** Read names from the source catalogue without creating scene objects or invented positions. */ -export function parseMoonCatalogue(input: unknown) { - const catalogue = sourceObject(input); - const moons = sourceArray(catalogue.moons, value => { - const moon = sourceObject(value); - return { id: sourceId(moon.id), name: sourceText(moon.name) }; - }); - sourceUnique(moons.map(moon => moon.id), 'moon identities'); - const count = catalogue.count; - if (!Number.isInteger(count) || count !== moons.length) throw new TypeError('Moon catalogue count does not match its entries.'); - return { moons }; -} - -const catalogues: Readonly>> = Object.fromEntries( - moonCatalogues.systems.map(system => [sourceId(system.id), parseMoonCatalogue(system)])); - -/** The moons a host's source catalogue names, in the catalogue's order; none for a host without one. */ -export const catalogueMoons = (hostId: string): readonly { readonly id: string; readonly name: string }[] => catalogues[hostId]?.moons ?? []; - /** A host's moons: the moons inside its system in the object tree, nearest the Sun first, or, where its moon catalogue * names them, in the catalogue's order with the moons that have no package yet. */ export function prepareBodyMoons(objectId: string): readonly MoonListEntry[] { From d5a3527e99de484439831bd7ef58ab4d410dcd2d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 5 Oct 2026 13:58:42 +0200 Subject: [PATCH 2/2] refactor(content): read moon catalogues from the shared reader in content and preparation --- .github/site-refactor.json | 4 +- docs/site-architecture.md | 3 +- docs/site-architecture/edits.json | 55 ---------------- docs/site-architecture/moves.json | 4 +- site/moon-catalogue.mts | 3 +- site/moon-catalogue.test.mts | 64 +++++++++++++++++++ site/object-children.mts | 2 +- ...epare-body-moons.characterization.test.mts | 25 ++++++++ site/prepare-body-moons.mts | 4 +- 9 files changed, 100 insertions(+), 64 deletions(-) create mode 100644 site/moon-catalogue.test.mts diff --git a/.github/site-refactor.json b/.github/site-refactor.json index 5ad32abfa69..683402f8958 100644 --- a/.github/site-refactor.json +++ b/.github/site-refactor.json @@ -2,6 +2,6 @@ "mode": "semantic", "moves": {}, "outputs": [], - "layout": "none", - "change": "S3-5 navigation requests read subjects from their owner instead of the selection module" + "layout": "changes", + "change": "S3-4 moon catalogue" } diff --git a/docs/site-architecture.md b/docs/site-architecture.md index 87774fa94f1..2d9746e5304 100644 --- a/docs/site-architecture.md +++ b/docs/site-architecture.md @@ -22,7 +22,7 @@ A folder imports itself or strictly lower layers. Sibling folders, including L0 | 1 | `directory/` | Startup reads and object catalogue directory | 16; `startup-world.mts`, `startup-requests.mts`, `object-entries.mts`, `object-directory.mts` | | 1 | `minimap/` | Surface-map measurements and view formatting | 4 | | 2 | `world/` | Shared framing, visibility and camera context | 73; `world-objects.mts`, `world-system-views.mts`, `context-availability.mts`, `context-datasets.mts` | -| 3 | `content/` | Card content, citations and metadata | 29; `dataset-content.mts`, `dataset-context.mts`, `object-text.mts`, `prepared-panel-content.mts` | +| 3 | `content/` | Card content, citations and metadata | 31; `dataset-content.mts`, `dataset-context.mts`, `object-text.mts`, `prepared-panel-content.mts` | | 3 | `navigation/` | History, requests, flights and arrivals | 25; `prepared-arrival.mts`, `prepared-scene-ownership.mts`, `arrival-billboard.mts`, `prepared-world-navigation.mts` | | 4 | `search/` | Catalogue search and result presentation | 6 | | 4 | `selection/` | Committed selection and camera handovers | 8; `scene/scene-selection.mts`, `satellite-selection.mts`, `overview-selection.mts`, `showcase.mts` | @@ -60,7 +60,6 @@ The changes array partitions stable edit ids into atomic groups. All seven group | Tier | Change / PR | Edit ids | Required code change | | ---: | --- | --- | --- | | 0 | Extract held address reader | add-file-navigation-href, add-import-navigation-history-navigation-href, retarget-application-world-resources-navigation-href | Move navigationHref AND its owners WeakMap. Export a registration function for history; preserve deferred URL semantics. History registers and unregisters its held address reader in the same WeakMap; re-export the getter for existing navigation callers. Only lower world caller uses navigationHref; other callers retain history behavior. | -| 3 | Split the moon catalogue reader | add-file-moon-catalogue, retarget-object-children-moon-catalogue, remove-import-prepare-body-moons-moon-catalogues, add-import-prepare-body-moons-moon-catalogue | Split catalogue parsing, cached source catalogue and catalogueMoons into a runtime-safe reader; preparation keeps search-based ordering. Read catalogueMoons from the split reader; production must never import build preparation. Move the source catalogue import with its parser into the reader; preparation calls that reader. Use the single catalogue reader for preparation too; preserve the named-moon eligibility policy in preparation. | | 0 | Relocate queued loader owners | add-file-scene-imports, add-file-world-imports, remove-queue-runtime, remove-shared-registry, remove-shared-world, retarget-adapter-scene-imports, retarget-router-world-imports, add-router-scene-imports, remove-directory-queue, retarget-boot-scene-imports, add-boot-world-imports | Decided by the owner: loader modules hold only import() calls through the existing queue. Own registry and packaged-runtime functions in scene; move the single last chain to lower browser/import-queue. Prove one queue chain, registration before the first scene call, the same entry-chunk modules and bytes, identical L1/L2/L3, L7 no worse and the real-iPad startup journey. Own the queued application-world import in world; no initializer inside the thunk. Move the runtime function out of the import-free browser queue implementation. Leave only importSceneRouter in startup/shared-imports. Move the world function to its owner. Adapter directly uses the same queued runtime function; preserve the frozen default adapter. Split router loader imports; preserve module-evaluation autostart. Import registry/runtime functions and register only the directory runtime loader using the existing object-directory import before first scene call. Directory accepts a typed runtime-loader registration; read the slot at scene invocation, not metadata construction. Missing registration fails explicitly. Body prestart and registry prestart reuse scene-owned promises. View prestart reuses the world-owned promise. | diff --git a/docs/site-architecture/edits.json b/docs/site-architecture/edits.json index 17154623719..6f58fe01ebc 100644 --- a/docs/site-architecture/edits.json +++ b/docs/site-architecture/edits.json @@ -28,52 +28,6 @@ "id": "retarget-application-world-resources-navigation-href", "line": 18 }, - { - "op": "add-file", - "path": "site/content/moon-catalogue.mts", - "note": "Split catalogue parsing, cached source catalogue and catalogueMoons into a runtime-safe reader; preparation keeps search-based ordering.", - "change": "Split the moon catalogue reader", - "imports": [ - { - "to": "site/source/moon-catalogues.json", - "kind": "asset" - }, - { - "to": "packages/objects/src/sources/index.ts", - "kind": "value" - } - ], - "id": "add-file-moon-catalogue" - }, - { - "op": "retarget", - "from": "site/content/object-children.mts", - "to": "site/build/prepare/prepare-body-moons.mts", - "note": "Read catalogueMoons from the split reader; production must never import build preparation.", - "newTo": "site/content/moon-catalogue.mts", - "change": "Split the moon catalogue reader", - "id": "retarget-object-children-moon-catalogue", - "line": 3 - }, - { - "op": "remove-import", - "from": "site/build/prepare/prepare-body-moons.mts", - "to": "site/source/moon-catalogues.json", - "note": "Move the source catalogue import with its parser into the reader; preparation calls that reader.", - "kind": "asset", - "change": "Split the moon catalogue reader", - "id": "remove-import-prepare-body-moons-moon-catalogues", - "line": 4 - }, - { - "op": "add-import", - "from": "site/build/prepare/prepare-body-moons.mts", - "to": "site/content/moon-catalogue.mts", - "kind": "value", - "note": "Use the single catalogue reader for preparation too; preserve the named-moon eligibility policy in preparation.", - "change": "Split the moon catalogue reader", - "id": "add-import-prepare-body-moons-moon-catalogue" - }, { "id": "add-file-scene-imports", "op": "add-file", @@ -209,15 +163,6 @@ "retarget-application-world-resources-navigation-href" ] }, - { - "id": "split-the-moon-catalogue-reader", - "edits": [ - "add-file-moon-catalogue", - "retarget-object-children-moon-catalogue", - "remove-import-prepare-body-moons-moon-catalogues", - "add-import-prepare-body-moons-moon-catalogue" - ] - }, { "id": "relocate-queued-loader-owners", "edits": [ diff --git a/docs/site-architecture/moves.json b/docs/site-architecture/moves.json index 9e60323b8c1..6ae9fac196b 100644 --- a/docs/site-architecture/moves.json +++ b/docs/site-architecture/moves.json @@ -354,5 +354,7 @@ "site/zoom-carry.characterization.test.mts": "site/world/zoom-carry.characterization.test.mts", "site/zoom-scope.characterization.test.mts": "site/world/zoom-scope.characterization.test.mts", "site/object-entry-types.mts": "site/directory/object-entry-types.mts", - "site/object-entry-types.test.mts": "site/directory/object-entry-types.test.mts" + "site/object-entry-types.test.mts": "site/directory/object-entry-types.test.mts", + "site/moon-catalogue.mts": "site/content/moon-catalogue.mts", + "site/moon-catalogue.test.mts": "site/content/moon-catalogue.test.mts" } diff --git a/site/moon-catalogue.mts b/site/moon-catalogue.mts index 111d3190d1f..907f53f12c2 100644 --- a/site/moon-catalogue.mts +++ b/site/moon-catalogue.mts @@ -20,4 +20,5 @@ const catalogues: Readonly> /** The moons a host's source catalogue names, in the catalogue's order; none for a host without one. */ export const catalogueMoons = (hostId: string): readonly { readonly id: string; readonly name: string }[] => catalogues[hostId]?.moons ?? []; -export { catalogues }; +/** Read the cached catalogue, preserving the distinction between an absent host and an empty catalogue. */ +export const readMoonCatalogue = (hostId: string) => catalogues[hostId]; diff --git a/site/moon-catalogue.test.mts b/site/moon-catalogue.test.mts new file mode 100644 index 00000000000..f6f41493ee6 --- /dev/null +++ b/site/moon-catalogue.test.mts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import moonCatalogues from './source/moon-catalogues.json' with { type: 'json' }; +import { sourceArray, sourceId, sourceObject, sourceText, sourceUnique } from '@cssearth/objects/sources'; +import { catalogueMoons, parseMoonCatalogue, readMoonCatalogue } from './moon-catalogue.mts'; +import ts from 'typescript'; + +// The pre-extraction reader, retained only as an independent parity oracle. +function oldParseMoonCatalogue(input: unknown) { + const catalogue = sourceObject(input); + const moons = sourceArray(catalogue.moons, value => { + const moon = sourceObject(value); + return { id: sourceId(moon.id), name: sourceText(moon.name) }; + }); + sourceUnique(moons.map(moon => moon.id), 'moon identities'); + const count = catalogue.count; + if (!Number.isInteger(count) || count !== moons.length) throw new TypeError('Moon catalogue count does not match its entries.'); + return { moons }; +} + +test('the whole source catalogue and validation errors match the old reader', () => { + for (const system of moonCatalogues.systems) { + assert.equal(JSON.stringify(parseMoonCatalogue(system)), JSON.stringify(oldParseMoonCatalogue(system)), system.id); + assert.deepEqual(catalogueMoons(system.id), oldParseMoonCatalogue(system).moons); + assert.strictEqual(catalogueMoons(system.id), readMoonCatalogue(system.id)?.moons); + assert.strictEqual(readMoonCatalogue(system.id), readMoonCatalogue(system.id)); + } + assert.equal(readMoonCatalogue('not-a-host'), undefined); + assert.deepEqual(catalogueMoons('not-a-host'), []); + for (const input of [null, {}, { count: 0, moons: [] }, { count: 1, moons: [] }, + { count: 1, moons: [{ id: 'Bad ID', name: 'Name' }] }, + { count: 2, moons: [{ id: 'moon', name: 'Moon' }, { id: 'moon', name: 'Moon' }] }]) { + const outcome = (parse: typeof parseMoonCatalogue) => { + try { return JSON.stringify(parse(input)); } + catch (error) { assert.ok(error instanceof Error); return `${error.name}: ${error.message}`; } + }; + assert.equal(outcome(parseMoonCatalogue), outcome(oldParseMoonCatalogue)); + } +}); + +/** Every module a file names, whether by import, re-export, dynamic import or inline import type, resolved against that file. */ +function modulesNamedBy(file: URL): string[] { + const named: string[] = []; + const visit = (node: ts.Node): void => { + const literal = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier + : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] + : ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) ? node.argument.literal : undefined; + if (literal && ts.isStringLiteral(literal)) named.push(literal.text.startsWith('.') ? new URL(literal.text, file).pathname : literal.text); + ts.forEachChild(node, visit); + }; + visit(ts.createSourceFile(file.pathname, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true)); + return named; +} + +test('production children read the shared reader and never name preparation; preparation has one reader', () => { + const children = modulesNamedBy(new URL('./object-children.mts', import.meta.url)); + const reader = new URL('./moon-catalogue.mts', import.meta.url).pathname; + assert.ok(children.includes(reader), 'Children use the shared reader'); + assert.ok(!children.includes(new URL('./prepare-body-moons.mts', import.meta.url).pathname), 'Children never import preparation'); + const preparation = modulesNamedBy(new URL('./prepare-body-moons.mts', import.meta.url)); + assert.ok(preparation.includes(reader), 'Preparation reads the shared catalogue'); + assert.ok(!preparation.some(module => module.endsWith('/moon-catalogues.json') || module === '@cssearth/objects/sources'), 'Preparation must not re-parse a separate catalogue'); +}); diff --git a/site/object-children.mts b/site/object-children.mts index 1829a5fb3ea..dd933e272ea 100644 --- a/site/object-children.mts +++ b/site/object-children.mts @@ -1,6 +1,6 @@ import { OBJECTS, ancestorsOf, type NavigableObject } from './objects.mts'; import { WORLD_HOST_ID } from './navigation/navigation-scope.mts'; -import { catalogueMoons } from './prepare-body-moons.mts'; +import { catalogueMoons } from './moon-catalogue.mts'; import { APPLICATION_WORLD_CONTEXT, APPLICATION_WORLD_FILE_OF } from './world-context-plan.mts'; const PARSEC_M = 3.085677581491367e16; diff --git a/site/prepare-body-moons.characterization.test.mts b/site/prepare-body-moons.characterization.test.mts index c2e18807508..f7c3d79075e 100644 --- a/site/prepare-body-moons.characterization.test.mts +++ b/site/prepare-body-moons.characterization.test.mts @@ -21,3 +21,28 @@ test('catalogue hosts retain source order, and unknown hosts have no moons', () assert.deepEqual(catalogueMoons('not-a-host'), []); assert.deepEqual(prepareBodyMoons('not-a-host'), []); }); + +// Whole-catalogue parity uses the actual production registry and search order. +test('preparation preserves the old serialized results, ordering and named-label eligibility', async () => { + const { default: input } = await import('./source/moon-catalogues.json', { with: { type: 'json' } }); + const { SEARCH_OBJECTS } = await import('./search/search-objects.mts'); + const { systemObjectId } = await import('./navigation/system-address.mts'); + const { readMoonCatalogue } = await import('./moon-catalogue.mts'); + for (const hostId of [...input.systems.map(system => system.id), ...new Set(SEARCH_OBJECTS.map(object => object.id)), 'not-a-host']) { + const available = SEARCH_OBJECTS.filter(object => object.classification === 'satellite' && object.parent === systemObjectId(hostId)); + const source = input.systems.find(system => system.id === hostId); + const byId = new Map(available.map(object => [object.id, object])); + const old = source ? source.moons.map(moon => ({ id: moon.id, name: moon.name, object: byId.get(moon.id) })) + : available.map(object => ({ id: object.id, name: object.name, object })); + assert.equal(JSON.stringify(prepareBodyMoons(hostId)), JSON.stringify(old), hostId); + if (source) assert.deepEqual(source.moons.filter(moon => hasProperMoonName(moon)), + source.moons.filter(moon => moon.name !== moon.provisionalDesignation)); + } + // The default production call must observe the shared cache, not another parse. + const cached = readMoonCatalogue('mars')!; + const original = cached.moons; + try { + cached.moons = [...original].reverse(); + assert.deepEqual(prepareBodyMoons('mars').map(moon => moon.id), cached.moons.map(moon => moon.id)); + } finally { cached.moons = original; } +}); diff --git a/site/prepare-body-moons.mts b/site/prepare-body-moons.mts index a24c5963af7..f20aa860112 100644 --- a/site/prepare-body-moons.mts +++ b/site/prepare-body-moons.mts @@ -1,7 +1,7 @@ import { SEARCH_OBJECTS } from './search/search-objects.mts'; import type { ObjectEntry } from './objects.mts'; import { systemObjectId } from './navigation/system-address.mts'; -import { catalogues } from './moon-catalogue.mts'; +import { readMoonCatalogue } from './moon-catalogue.mts'; export { parseMoonCatalogue, catalogueMoons } from './moon-catalogue.mts'; import { labelEligible } from '@cssearth/renderer/labels/universe-label-policy.ts'; @@ -18,7 +18,7 @@ export function hasProperMoonName(moon: { name: string; provisionalDesignation: export function prepareBodyMoons(objectId: string): readonly MoonListEntry[] { const system = systemObjectId(objectId); const available = SEARCH_OBJECTS.filter(object => object.classification === 'satellite' && object.parent === system); - const catalogue = catalogues[objectId]; + const catalogue = readMoonCatalogue(objectId); if (!catalogue) return available.map(object => ({ id: object.id, name: object.name, object })); const byId = new Map(available.map(object => [object.id, object])); const knownIds = new Set(catalogue.moons.map(moon => moon.id));