@hearthlog/tooling 0.0.0-stage → 0.1.0

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.
Files changed (85) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +52 -0
  3. package/package.json +64 -3
  4. package/src/apply.ts +73 -0
  5. package/src/check/check-layers.ts +118 -0
  6. package/src/check/check-modules.ts +198 -0
  7. package/src/check/check-sdk-exports.ts +79 -0
  8. package/src/check/check-tenant-config.ts +208 -0
  9. package/src/cli/hearthlog.ts +212 -0
  10. package/src/cli/list-sites.ts +15 -0
  11. package/src/compose/compose-modules.ts +490 -0
  12. package/src/compose/module-scaffold.ts +194 -0
  13. package/src/deploy/artifact-compare.ts +93 -0
  14. package/src/deploy/artifact.ts +127 -0
  15. package/src/deploy/async-utils.ts +71 -0
  16. package/src/deploy/backup-tenants.ts +139 -0
  17. package/src/deploy/cloudflare-access.ts +789 -0
  18. package/src/deploy/copy-tenant.ts +395 -0
  19. package/src/deploy/dedicated-ledger.ts +77 -0
  20. package/src/deploy/deploy-ledger.ts +162 -0
  21. package/src/deploy/deploy-runner.ts +240 -0
  22. package/src/deploy/deploy-tenant.ts +170 -0
  23. package/src/deploy/deployment-security.ts +141 -0
  24. package/src/deploy/ensure-tenant.ts +988 -0
  25. package/src/deploy/event-policy.ts +23 -0
  26. package/src/deploy/frontend-dry-run.ts +18 -0
  27. package/src/deploy/ledger-credential.ts +48 -0
  28. package/src/deploy/ledger-scripts.ts +13 -0
  29. package/src/deploy/split-tenant.ts +295 -0
  30. package/src/dev/local-development.ts +288 -0
  31. package/src/dev/provision-local.ts +13 -0
  32. package/src/generate/build-generated.ts +166 -0
  33. package/src/generate/generate-brand-icons.ts +128 -0
  34. package/src/generate/generate-entities.ts +627 -0
  35. package/src/generate/generate-tenant.ts +161 -0
  36. package/src/host/core-routes.ts +39 -0
  37. package/src/host/write-host.ts +119 -0
  38. package/src/index.ts +8 -0
  39. package/src/init/check-module.ts +143 -0
  40. package/src/init/export-tenant.ts +124 -0
  41. package/src/init/init.ts +167 -0
  42. package/src/move.ts +189 -0
  43. package/src/plan.ts +160 -0
  44. package/src/public-workers/build-gallery-public.ts +114 -0
  45. package/src/public-workers/calendar-public-config.ts +27 -0
  46. package/src/public-workers/check-calendar-public-route.ts +41 -0
  47. package/src/public-workers/check-gallery-public-route.ts +43 -0
  48. package/src/public-workers/gallery-public-config.ts +22 -0
  49. package/src/receipt.ts +74 -0
  50. package/src/root.ts +16 -0
  51. package/src/security/audit-push.ts +136 -0
  52. package/src/security/forbidden-identities.ts +107 -0
  53. package/src/security/forbidden-init.ts +88 -0
  54. package/src/tenants/host-configs.ts +96 -0
  55. package/src/tenants/ledger-config.ts +57 -0
  56. package/src/tenants/lock-file.ts +45 -0
  57. package/src/tenants/module-lock.ts +58 -0
  58. package/src/tenants/tenant-config.ts +887 -0
  59. package/src/tenants/tenant-copy-plan.ts +119 -0
  60. package/src/tenants/tenant-schema.ts +171 -0
  61. package/src/tenants/tenant-wrangler.ts +37 -0
  62. package/src/tenants/wrangler-base.ts +50 -0
  63. package/src/tenants/wrangler-config.ts +266 -0
  64. package/src/vite/config.ts +310 -0
  65. package/src/vite/dev-plugins.ts +338 -0
  66. package/src/vite/vite-plugin-entity-gen.ts +70 -0
  67. package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
  68. package/src/vite/vite-plugin-worker-assets.ts +42 -0
  69. package/templates/host/app.d.ts.txt +31 -0
  70. package/templates/host/microlighter.d.ts.txt +11 -0
  71. package/templates/module/ci.yml.tmpl +44 -0
  72. package/templates/module/gitignore.tmpl +7 -0
  73. package/templates/module/release.yml.tmpl +53 -0
  74. package/templates/tenant/README.md.tmpl +13 -0
  75. package/templates/tenant/generated-package.json.tmpl +16 -0
  76. package/templates/tenant/github-workflows/deploy.yml.tmpl +110 -0
  77. package/templates/tenant/gitignore.tmpl +11 -0
  78. package/templates/tenant/package.json.tmpl +11 -0
  79. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  80. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  81. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  82. package/templates/tenant/tenants.json.tmpl +17 -0
  83. package/templates/tenant/tsconfig.json.tmpl +21 -0
  84. package/templates/tenant/vite.config.ts.tmpl +5 -0
  85. package/README.md +0 -3
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Compose the app from the modules in `modules/*`.
4
+ *
5
+ * Two outputs, both derived from each module's `hearthlog.module.json` (read as
6
+ * data; no module code runs here):
7
+ *
8
+ * --catalog The module catalog of one site (`--site <id>`, default the
9
+ * build site) or of every installed module (`--all`): feature
10
+ * metadata, sub-pages, shortcuts, refresh keys, icons, the data
11
+ * repositories, and the site's gateway table. Written to the
12
+ * gitignored `.generated/` by `pnpm build:generated`, which runs
13
+ * this first because everything else reads it. A site's modules
14
+ * are the ones in its `tenants/<id>/modules.lock.json`.
15
+ * (default) The routes of one tenant (VITE_STOR_TENANT, default "st"):
16
+ * thin wrappers in the gitignored `src/routes/(modules)/` for
17
+ * the modules whose features the tenant enables, so a tenant
18
+ * without a module ships none of its pages.
19
+ *
20
+ * Usage:
21
+ * pnpm build:generated (runs both, and the entity and tenant generators)
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
25
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
26
+ import { dirname, join, relative, resolve, sep } from "node:path";
27
+ import { fileURLToPath, pathToFileURL } from "node:url";
28
+ import { isDeepStrictEqual } from "node:util";
29
+ import { CORE_ROUTE_PATHS } from "../host/core-routes.js";
30
+ import { hostRoot } from "../root.js";
31
+
32
+ import { findCollisions, validateManifest, type ModuleManifest } from "@hearthlog/contracts";
33
+
34
+ import { CORE_API_PREFIXES, CORE_FEATURE_IDS, CORE_REFRESH_KEYS } from "@hearthlog/contracts/features/core";
35
+
36
+ // The catalog must build before anything that reads it (tenant-config imports
37
+ // the registry, which imports the catalog), so this script resolves the root itself.
38
+ const REPOSITORY_ROOT = hostRoot();
39
+ const MODULES_DIRECTORY = join(REPOSITORY_ROOT, "modules");
40
+ /** The routes the site's own `src/routes/` adds; `pnpm build:generated` merges them into the generated routes. */
41
+ const SITE_ROUTES_DIRECTORY = join(REPOSITORY_ROOT, "src", "routes");
42
+ const COMPOSED_ROUTES = join(REPOSITORY_ROOT, ".generated", "routes", "(modules)");
43
+ /** What the build writes (gitignored): the shared package for the active site and a folder per site built so far. */
44
+ export const GENERATED_DIRECTORY = join(REPOSITORY_ROOT, ".generated");
45
+ export function siteDirectory(siteId: string): string {
46
+ return join(GENERATED_DIRECTORY, "sites", siteId);
47
+ }
48
+ /** The server code of the modules a site enables, which the site's gateway Worker bundles. */
49
+ export function gatewayModulesPath(siteId: string): string {
50
+ return join(siteDirectory(siteId), "server-modules.ts");
51
+ }
52
+ /** The site's gateway Worker entry: Wrangler's `main`. */
53
+ export function gatewayEntryPath(siteId: string): string {
54
+ return join(siteDirectory(siteId), "worker.ts");
55
+ }
56
+ /** Each tenant lists the module stylesheets and sources it ships for Tailwind (written by `pnpm build:generated`). */
57
+ export const MODULE_SOURCES_PATH = join(REPOSITORY_ROOT, ".generated", "module-sources.generated.css");
58
+ export const CATALOG_PATH = join(GENERATED_DIRECTORY, "src", "modules.generated.ts");
59
+ export const ICONS_PATH = join(GENERATED_DIRECTORY, "src", "module-icons.generated.ts");
60
+ export const REPOSITORIES_PATH = join(GENERATED_DIRECTORY, "src", "module-repositories.generated.ts");
61
+
62
+ export interface LoadedModule {
63
+ directory: string;
64
+ manifest: ModuleManifest;
65
+ }
66
+
67
+ export function loadModuleAt(moduleDirectory: string): LoadedModule {
68
+ const path = join(moduleDirectory, "hearthlog.module.json");
69
+ const manifest = validateManifest(JSON.parse(readFileSync(path, "utf8")), relative(REPOSITORY_ROOT, path));
70
+ const packageJson = JSON.parse(readFileSync(join(moduleDirectory, "package.json"), "utf8")) as { name?: string };
71
+ if (packageJson.name !== manifest.package) {
72
+ throw new Error(`${manifest.id}: package.json name ${String(packageJson.name)} does not match ${manifest.package}`);
73
+ }
74
+ for (const route of manifest.routes) {
75
+ for (const file of [route.page, route.load, ...(manifest.layouts ?? []).flatMap((layout) => [layout.page, layout.load]), manifest.data?.definitions, manifest.data?.repositories, manifest.server?.entry, manifest.startup].filter((value): value is string => Boolean(value))) {
76
+ if (!existsSync(join(moduleDirectory, file))) throw new Error(`${manifest.id}: ${file} does not exist`);
77
+ }
78
+ }
79
+ return { directory: moduleDirectory, manifest };
80
+ }
81
+
82
+ /** The packages the host installs that carry a module manifest: its dependencies with a `hearthlog.module.json` in their root. */
83
+ export function installedModuleDirectories(root = REPOSITORY_ROOT): string[] {
84
+ const packagePath = join(root, "package.json");
85
+ if (!existsSync(packagePath)) return [];
86
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
87
+ return Object.keys({ ...packageJson.devDependencies, ...packageJson.dependencies })
88
+ .map((name) => join(root, "node_modules", name))
89
+ .filter((directory) => existsSync(join(directory, "hearthlog.module.json")))
90
+ .map((directory) => realpathSync(directory));
91
+ }
92
+
93
+ /**
94
+ * The modules a host has: those in its `modules/` folder (a checkout that develops them) and the packages
95
+ * it installs (a site that uses them). Given a directory, only that folder is scanned.
96
+ */
97
+ export function loadModules(directory?: string): LoadedModule[] {
98
+ const scanned = (folder: string): string[] => existsSync(folder)
99
+ ? readdirSync(folder, { withFileTypes: true })
100
+ .filter((entry) => entry.isDirectory() && existsSync(join(folder, entry.name, "hearthlog.module.json")))
101
+ .map((entry) => join(folder, entry.name))
102
+ : [];
103
+ const directories = directory !== undefined ? scanned(directory) : [...scanned(MODULES_DIRECTORY), ...installedModuleDirectories()];
104
+ const unique = [...new Map(directories.map((candidate) => [realpathSync(candidate), candidate])).values()];
105
+ return unique.map(loadModuleAt).sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
106
+ }
107
+
108
+ /** URL paths of the app's own routes, outside the composed group: the core routes and the site's `src/routes/`. */
109
+ export function coreRoutePaths(directory = SITE_ROUTES_DIRECTORY): string[] {
110
+ const paths: string[] = [...CORE_ROUTE_PATHS];
111
+ if (!existsSync(directory)) return paths;
112
+ const visit = (current: string, url: string) => {
113
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
114
+ if (!entry.isDirectory() || entry.name === "(modules)") continue;
115
+ const segment = /^\(.+\)$/.test(entry.name) ? "" : `/${entry.name}`;
116
+ const next = join(current, entry.name);
117
+ if (existsSync(join(next, "+page.svelte"))) paths.push(url + segment || "/");
118
+ visit(next, url + segment);
119
+ }
120
+ };
121
+ visit(directory, "");
122
+ return paths;
123
+ }
124
+
125
+ export function checkComposition(modules: readonly LoadedModule[]): void {
126
+ const problems = findCollisions(modules.map((module) => module.manifest), {
127
+ featureIds: CORE_FEATURE_IDS,
128
+ routePaths: coreRoutePaths(),
129
+ refreshKeys: CORE_REFRESH_KEYS,
130
+ apiPrefixes: CORE_API_PREFIXES,
131
+ });
132
+ if (problems.length > 0) throw new Error(`Modules cannot be composed:\n - ${problems.join("\n - ")}`);
133
+ }
134
+
135
+ /** A data entry (`./src/data/x.ts`) as the package exports it. */
136
+ function exportedData(manifest: ModuleManifest, file: string): string {
137
+ const match = /^\.\/src\/data\/(.+)\.ts$/.exec(file);
138
+ if (!match) throw new Error(`${manifest.id}: ${file} must live in src/data/`);
139
+ return `${manifest.package}/data/${match[1]}.js`;
140
+ }
141
+
142
+ function exportedServer(manifest: ModuleManifest): string {
143
+ const match = /^\.\/src\/server\/(.+)\.ts$/.exec(manifest.server!.entry);
144
+ if (!match) throw new Error(`${manifest.id}: server.entry must live in src/server/`);
145
+ return `${manifest.package}/server/${match[1]}.js`;
146
+ }
147
+
148
+ function exportedStartup(manifest: ModuleManifest): string {
149
+ const match = /^\.\/src\/(.+)\.ts$/.exec(manifest.startup!);
150
+ if (!match) throw new Error(`${manifest.id}: startup must be a .ts file in src/`);
151
+ return `${manifest.package}/${match[1]}.js`;
152
+ }
153
+
154
+ function exportedStyles(manifest: ModuleManifest): string {
155
+ const match = /^\.\/src\/(.+\.css)$/.exec(manifest.styles!);
156
+ if (!match) throw new Error(`${manifest.id}: styles must be a .css file in src/`);
157
+ return `${manifest.package}/${match[1]}`;
158
+ }
159
+
160
+ function exportedAttention(manifest: ModuleManifest): string {
161
+ const match = /^\.\/src\/(.+)\.ts$/.exec(manifest.attention!);
162
+ if (!match) throw new Error(`${manifest.id}: attention must be a .ts file in src/`);
163
+ return `${manifest.package}/${match[1]}.js`;
164
+ }
165
+
166
+ /** Entry path inside a module, as its package exports it. */
167
+ function exportedEntry(manifest: ModuleManifest, file: string): string {
168
+ const match = /^\.\/src\/pages\/(.+)\.(svelte|ts)$/.exec(file);
169
+ if (!match) throw new Error(`${manifest.id}: ${file} must live in src/pages/ to be routed`);
170
+ return `${manifest.package}/pages/${match[1]}.${match[2] === "ts" ? "js" : "svelte"}`;
171
+ }
172
+
173
+ const HEADER = "// AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts from modules/*/hearthlog.module.json. Do not edit.";
174
+
175
+ /**
176
+ * The entity definitions of the modules that ship any, in module order. They are plain data and are written into the
177
+ * catalog as literals: the catalog is imported by the site's Vite configuration, which Node loads, and Node refuses
178
+ * the TypeScript of a module installed under `node_modules`.
179
+ */
180
+ export async function loadEntityDefinitions(modules: readonly LoadedModule[]): Promise<unknown[]> {
181
+ const lists = await Promise.all(modules.filter(({ manifest }) => manifest.data?.definitions).map(async ({ directory, manifest }) => {
182
+ const loaded = await import(pathToFileURL(join(directory, manifest.data!.definitions!)).href) as { ENTITY_DEFINITIONS?: unknown[] };
183
+ if (!Array.isArray(loaded.ENTITY_DEFINITIONS)) throw new Error(`${manifest.id}: ${manifest.data!.definitions} must export ENTITY_DEFINITIONS`);
184
+ if (JSON.stringify(loaded.ENTITY_DEFINITIONS) === undefined || !isDeepStrictEqual(JSON.parse(JSON.stringify(loaded.ENTITY_DEFINITIONS)), loaded.ENTITY_DEFINITIONS)) {
185
+ throw new Error(`${manifest.id}: ENTITY_DEFINITIONS must be plain data (no functions, patterns, or undefined values)`);
186
+ }
187
+ return loaded.ENTITY_DEFINITIONS;
188
+ }));
189
+ return lists.flat();
190
+ }
191
+
192
+ export function renderCatalog(modules: readonly LoadedModule[], entityDefinitions: readonly unknown[]): string {
193
+ const features = modules.flatMap(({ manifest }) => manifest.features.map((feature) => ({ module: manifest.id, ...feature })));
194
+ const refreshKeys = modules.flatMap(({ manifest }) => manifest.refreshKeys ?? []);
195
+ const ids = features.map((feature) => JSON.stringify(feature.id));
196
+ const sections = modules.flatMap(({ manifest }) => manifest.section ? [manifest.section] : []);
197
+ const routes = modules.flatMap(({ manifest }) => manifest.routes.map((route) => ({
198
+ path: route.path,
199
+ ...(route.fitsScreen ? { fitsScreen: true } : {}),
200
+ ...(route.keepMounted ? { keepMounted: true } : {}),
201
+ ...(route.resolvesTo ? { resolvesTo: route.resolvesTo } : {}),
202
+ ...(route.requires ? { requires: route.requires } : {}),
203
+ })));
204
+ const redirects = modules.flatMap(({ manifest }) => manifest.redirects ?? []);
205
+ const integrationKinds = modules.flatMap(({ manifest }) => manifest.integrationKinds ?? []);
206
+ const aiCapabilities = modules.flatMap(({ manifest }) => manifest.aiCapabilities ?? []);
207
+ const localStores = modules.flatMap(({ manifest }) => manifest.localStores ?? []);
208
+ const projections = modules.flatMap(({ manifest }) => manifest.projectionVersion === undefined ? [] : [{
209
+ module: manifest.id,
210
+ version: manifest.projectionVersion,
211
+ features: manifest.features.map((feature) => feature.id),
212
+ }]);
213
+ return [
214
+ HEADER,
215
+ "",
216
+ "import type { ModuleFeature, ModuleRedirect, ModuleRoute, ModuleSection } from '@hearthlog/contracts';",
217
+ "import type { EntityDefinition } from '@hearthlog/contracts/event/entity-definitions';",
218
+ "",
219
+ `export type ModuleFeatureId = ${ids.length ? ids.join(" | ") : "never"};`,
220
+ "",
221
+ "export const MODULE_FEATURES: ReadonlyArray<ModuleFeature & { id: ModuleFeatureId; module: string }> = " +
222
+ `${JSON.stringify(features, null, 4)};`,
223
+ "",
224
+ `export const MODULE_REFRESH_KEYS: readonly \`\${string}:\${string}\`[] = ${JSON.stringify(refreshKeys)};`,
225
+ "",
226
+ `export const MODULE_LOCAL_STORES: ReadonlyArray<{ database: string; label: string; fileStore?: string; kinds?: boolean }> = ${JSON.stringify(localStores, null, 4)};`,
227
+ "",
228
+ `export const MODULE_SECTIONS: readonly ModuleSection[] = ${JSON.stringify(sections, null, 4)};`,
229
+ "",
230
+ `export const MODULE_ROUTES: ReadonlyArray<Pick<ModuleRoute, 'path' | 'fitsScreen' | 'keepMounted' | 'resolvesTo' | 'requires'>> = ${JSON.stringify(routes, null, 4)};`,
231
+ "",
232
+ `export const MODULE_REDIRECTS: readonly ModuleRedirect[] = ${JSON.stringify(redirects, null, 4)};`,
233
+ "",
234
+ `export const MODULE_PROJECTIONS: ReadonlyArray<{ module: string; version: number; features: readonly string[] }> = ${JSON.stringify(projections, null, 4)};`,
235
+ "",
236
+ `export type ModuleAiCapability = ${aiCapabilities.length ? aiCapabilities.map((id) => JSON.stringify(id)).join(" | ") : "never"};`,
237
+ "",
238
+ `export const MODULE_AI_CAPABILITIES: readonly ModuleAiCapability[] = ${JSON.stringify(aiCapabilities)};`,
239
+ "",
240
+ `export type ModuleIntegrationKind = ${integrationKinds.length ? integrationKinds.map((entry) => JSON.stringify(entry.kind)).join(" | ") : "never"};`,
241
+ "",
242
+ `export const MODULE_INTEGRATION_KINDS: ReadonlyArray<{ kind: ModuleIntegrationKind; feature: ModuleFeatureId }> = ${JSON.stringify(integrationKinds, null, 4)} as ReadonlyArray<{ kind: ModuleIntegrationKind; feature: ModuleFeatureId }>;`,
243
+ "",
244
+ `export const MODULE_ENTITY_DEFINITIONS: readonly EntityDefinition[] = ${JSON.stringify(entityDefinitions, null, 4)};`,
245
+ "",
246
+ ].join("\n");
247
+ }
248
+
249
+ /** Browser-only: the read repositories modules add to `queryDatabase`. */
250
+ export function renderRepositories(modules: readonly LoadedModule[]): string {
251
+ const withRepositories = modules.filter(({ manifest }) => manifest.data?.repositories);
252
+ return [
253
+ HEADER,
254
+ "",
255
+ "import type { ReadModelSession } from '@hearthlog/runtime/db/read-session.js';",
256
+ ...withRepositories.map(({ manifest }, index) => `import { createRepositories as moduleRepositories${index} } from '${exportedData(manifest, manifest.data!.repositories!)}';`),
257
+ "",
258
+ "export function createModuleRepositories(session: ReadModelSession) {",
259
+ " return {",
260
+ ...withRepositories.map((_, index) => ` ...moduleRepositories${index}(session),`),
261
+ " };",
262
+ "}",
263
+ "",
264
+ ].join("\n");
265
+ }
266
+
267
+ export function renderIcons(modules: readonly LoadedModule[]): string {
268
+ const features = modules.flatMap(({ manifest }) => manifest.features);
269
+ const sections = modules.flatMap(({ manifest }) => manifest.section ? [manifest.section] : []);
270
+ const calendarIcons = [...new Set(modules.flatMap(({ manifest }) => manifest.calendarIcons ?? []))].sort();
271
+ return [
272
+ HEADER,
273
+ "",
274
+ "import type { Component } from 'svelte';",
275
+ ...features.map((feature, index) => `import ModuleIcon${index} from '~icons/mdi/${feature.icon}';`),
276
+ ...sections.map((section, index) => `import SectionIcon${index} from '~icons/mdi/${section.icon}';`),
277
+ ...calendarIcons.map((icon, index) => `import CalendarIcon${index} from '~icons/mdi/${icon}';`),
278
+ "import type { ModuleFeatureId } from './modules.generated.js';",
279
+ "",
280
+ "export const MODULE_ICONS: Record<ModuleFeatureId, Component> = {",
281
+ ...features.map((feature, index) => ` ${JSON.stringify(feature.id)}: ModuleIcon${index},`),
282
+ "};",
283
+ "",
284
+ "export const MODULE_SECTION_ICONS: Record<string, Component> = {",
285
+ ...sections.map((section, index) => ` ${JSON.stringify(section.id)}: SectionIcon${index},`),
286
+ "};",
287
+ "",
288
+ "/** Icons modules use on their calendar sources, by `mdi` name. */",
289
+ "export const MODULE_CALENDAR_ICONS: Record<string, Component> = {",
290
+ ...calendarIcons.map((icon, index) => ` ${JSON.stringify(icon)}: CalendarIcon${index},`),
291
+ "};",
292
+ "",
293
+ ].join("\n");
294
+ }
295
+
296
+ /**
297
+ * The gateway Worker's module table for one site: only the server entries of
298
+ * modules the site enables, so a site without Gallery bundles no Gallery code.
299
+ * The site's gateway entry (`worker.ts`) imports this file.
300
+ */
301
+ export function renderGatewayModules(siteId: string, modules: readonly LoadedModule[]): string {
302
+ const servers = modules.filter(({ manifest }) => manifest.server);
303
+ return [
304
+ `// AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts for site "${siteId}". Do not edit.`,
305
+ "",
306
+ "import type { ServerModule } from '@hearthlog/contracts';",
307
+ ...servers.map(({ manifest }, index) => `import serverModule${index} from '${exportedServer(manifest)}';`),
308
+ "",
309
+ `export const SERVER_MODULES: ReadonlyArray<ServerModule<Env>> = [${servers.map((_, index) => `serverModule${index}`).join(", ")}];`,
310
+ "",
311
+ ].join("\n");
312
+ }
313
+
314
+ export function renderModuleSources(modules: readonly LoadedModule[]): string {
315
+ const styles = modules.filter(({ manifest }) => manifest.styles).map(({ manifest }) => `@import '${exportedStyles(manifest)}';`);
316
+ const sources = modules.map(({ directory }) => `@source '${relative(dirname(MODULE_SOURCES_PATH), join(directory, "src")).split(sep).join("/")}';`);
317
+ return ["/* AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts. Do not edit. */", ...styles, ...sources, ""].join("\n");
318
+ }
319
+
320
+ /** Browser code a tenant's modules run at startup (Gallery's upload coordinator). */
321
+ export function renderStartup(modules: readonly LoadedModule[]): string {
322
+ const starters = modules.filter(({ manifest }) => manifest.startup);
323
+ const watchers = modules.filter(({ manifest }) => manifest.attention);
324
+ return [
325
+ `// AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts. Do not edit.`,
326
+ "",
327
+ "import type { AttentionContribution } from '@hearthlog/contracts';",
328
+ ...starters.map(({ manifest }, index) => `import { start as start${index} } from '${exportedStartup(manifest)}';`),
329
+ ...watchers.map(({ manifest }, index) => `import { attention as attention${index} } from '${exportedAttention(manifest)}';`),
330
+ "",
331
+ "/** What the composed modules say about late or upcoming work (navigation badges, native reminders). */",
332
+ `export const attentionContributions: readonly AttentionContribution[] = [${watchers.map((_, index) => `attention${index}`).join(", ")}];`,
333
+ "",
334
+ "/** Starts every composed module; returns one function that stops them all. */",
335
+ "export function startModules(): () => void {",
336
+ ` const stops: unknown[] = [${starters.map((_, index) => `start${index}()`).join(", ")}];`,
337
+ " return () => { for (const stop of stops) if (typeof stop === 'function') stop(); };",
338
+ "}",
339
+ "",
340
+ ].join("\n");
341
+ }
342
+
343
+ export function renderRoute(manifest: ModuleManifest, route: ModuleManifest["routes"][number]): { page: string; load: string | null } {
344
+ const header = `<!-- AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts from ${manifest.id}. Do not edit. -->`;
345
+ return {
346
+ page: [
347
+ header,
348
+ '<script lang="ts">',
349
+ ` import Page from "${exportedEntry(manifest, route.page)}";`,
350
+ " let props = $props();",
351
+ "</script>",
352
+ "",
353
+ "<Page {...props} />",
354
+ "",
355
+ ].join("\n"),
356
+ load: route.load ? `${HEADER}\nexport { load } from "${exportedEntry(manifest, route.load)}";\n` : null,
357
+ };
358
+ }
359
+
360
+ /** A layout wrapper: the module's layout component gets the route's props, children included. */
361
+ export function renderLayout(manifest: ModuleManifest, layout: NonNullable<ModuleManifest["layouts"]>[number]): { page: string; load: string | null } {
362
+ const header = `<!-- AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts from ${manifest.id}. Do not edit. -->`;
363
+ return {
364
+ page: [
365
+ header,
366
+ '<script lang="ts">',
367
+ ` import Layout from "${exportedEntry(manifest, layout.page)}";`,
368
+ " let { children, ...props } = $props();",
369
+ "</script>",
370
+ "",
371
+ // SvelteKit requires the layout file itself to render its children.
372
+ "<Layout {...props}>{@render children()}</Layout>",
373
+ "",
374
+ ].join("\n"),
375
+ load: layout.load ? `${HEADER}\nexport { load } from "${exportedEntry(manifest, layout.load)}";\n` : null,
376
+ };
377
+ }
378
+
379
+ /** The modules a tenant composes: those with at least one enabled feature. */
380
+ export function modulesForFeatures(modules: readonly LoadedModule[], features: readonly string[]): LoadedModule[] {
381
+ return modules.filter(({ manifest }) => manifest.features.some((feature) => features.includes(feature.id)));
382
+ }
383
+
384
+ /** The modules a site ships, as its lock names them. */
385
+ export function modulesInLock(modules: readonly LoadedModule[], siteId: string): LoadedModule[] {
386
+ const path = join(REPOSITORY_ROOT, "tenants", siteId, "modules.lock.json");
387
+ if (!existsSync(path)) throw new Error(`tenants/${siteId}/modules.lock.json is missing (run pnpm lock:modules)`);
388
+ const ids = (JSON.parse(readFileSync(path, "utf8")) as { modules: Array<{ id: string }> }).modules.map(({ id }) => id);
389
+ const unknown = ids.filter((id) => !modules.some(({ manifest }) => manifest.id === id));
390
+ if (unknown.length > 0) throw new Error(`tenants/${siteId}/modules.lock.json names modules that are not installed: ${unknown.join(", ")}`);
391
+ return modules.filter(({ manifest }) => ids.includes(manifest.id));
392
+ }
393
+
394
+ async function writeIfChanged(path: string, content: string): Promise<void> {
395
+ if (existsSync(path) && (await readFile(path, "utf8")) === content) return;
396
+ await mkdir(dirname(path), { recursive: true });
397
+ await writeFile(path, content, "utf8");
398
+ }
399
+
400
+ /** The gateway Worker's entry: the code is `@hearthlog/cloudflare`; the site hands it the server modules it enables. */
401
+ export function renderGatewayEntry(siteId: string): string {
402
+ return [
403
+ `// AUTO-GENERATED by packages/tooling/src/compose/compose-modules.ts for site "${siteId}". Do not edit.`,
404
+ "",
405
+ "import { createGateway } from '@hearthlog/cloudflare/gateway.js';",
406
+ "",
407
+ "import { SERVER_MODULES } from './server-modules.js';",
408
+ "",
409
+ "export { CalendarFeedRenderer } from '@hearthlog/cloudflare/calendar-feed-renderer.js';",
410
+ "export { proxyAccessSession, proxyLedgerAdminRequest, proxyLedgerRequest, serveAssetOrAppShell } from '@hearthlog/cloudflare/gateway.js';",
411
+ "",
412
+ "export default createGateway(SERVER_MODULES);",
413
+ "",
414
+ ].join("\n");
415
+ }
416
+
417
+ export interface CatalogOptions {
418
+ /** The site whose modules the catalog covers; ignored with `all`. */
419
+ siteId: string;
420
+ /** Every installed module, for the commands that read the lock's source (`pnpm lock:modules`). */
421
+ all?: boolean;
422
+ check?: boolean;
423
+ }
424
+
425
+ export async function generateCatalog({ siteId, all = false, check = false }: CatalogOptions): Promise<void> {
426
+ const installed = loadModules();
427
+ checkComposition(installed);
428
+ const modules = all ? installed : modulesInLock(installed, siteId);
429
+ const outputs = new Map([
430
+ [CATALOG_PATH, renderCatalog(modules, await loadEntityDefinitions(modules))],
431
+ [ICONS_PATH, renderIcons(modules)],
432
+ [REPOSITORIES_PATH, renderRepositories(modules)],
433
+ ]);
434
+ if (!all) {
435
+ outputs.set(gatewayModulesPath(siteId), renderGatewayModules(siteId, modules));
436
+ outputs.set(gatewayEntryPath(siteId), renderGatewayEntry(siteId));
437
+ }
438
+ const stale: string[] = [];
439
+ for (const [path, content] of outputs) {
440
+ if (existsSync(path) && readFileSync(path, "utf8") === content) continue;
441
+ if (check) stale.push(relative(REPOSITORY_ROOT, path));
442
+ else await writeIfChanged(path, content);
443
+ }
444
+ if (stale.length) throw new Error(`Generated module files are stale: ${stale.join(", ")} (run pnpm build:generated)`);
445
+ console.log(`${check ? "Checked" : "Generated"} the catalog of ${modules.length} module(s)${all ? " (every installed module)" : ` for ${siteId}`}.`);
446
+ }
447
+
448
+ async function composeRoutes(): Promise<void> {
449
+ const modules = loadModules();
450
+ checkComposition(modules);
451
+ const siteId = process.env.VITE_STOR_TENANT?.trim() || "st";
452
+ const { loadTenantConfig, siteById } = await import("../tenants/tenant-config.js");
453
+ const { sites } = await loadTenantConfig(undefined, { only: siteId });
454
+ const site = siteById(sites, siteId);
455
+ const selected = modulesForFeatures(modules, site.features);
456
+ await rm(COMPOSED_ROUTES, { recursive: true, force: true });
457
+ for (const { manifest } of selected) {
458
+ for (const route of manifest.routes) {
459
+ const directory = join(COMPOSED_ROUTES, ...route.path.split("/").filter(Boolean));
460
+ const rendered = renderRoute(manifest, route);
461
+ await writeIfChanged(join(directory, "+page.svelte"), rendered.page);
462
+ if (rendered.load) await writeIfChanged(join(directory, "+page.ts"), rendered.load);
463
+ }
464
+ }
465
+ for (const { manifest } of selected) {
466
+ for (const layout of manifest.layouts ?? []) {
467
+ const directory = join(COMPOSED_ROUTES, ...layout.path.split("/").filter(Boolean));
468
+ const rendered = renderLayout(manifest, layout);
469
+ await writeIfChanged(join(directory, "+layout.svelte"), rendered.page);
470
+ if (rendered.load) await writeIfChanged(join(directory, "+layout.ts"), rendered.load);
471
+ }
472
+ }
473
+ await writeIfChanged(join(COMPOSED_ROUTES, "startup.ts"), renderStartup(selected));
474
+ await writeIfChanged(MODULE_SOURCES_PATH, renderModuleSources(selected));
475
+ console.log(`Composed ${selected.length} of ${modules.length} module(s) for ${site.id}: ${selected.map(({ manifest }) => manifest.id).join(", ") || "none"}.`);
476
+ }
477
+
478
+ export async function main(argv = process.argv.slice(2)): Promise<void> {
479
+ const site = argv.indexOf("--site");
480
+ const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? (process.env.VITE_STOR_TENANT?.trim() || "st");
481
+ if (argv.includes("--catalog")) await generateCatalog({ siteId, all: argv.includes("--all"), check: argv.includes("--check") });
482
+ else await composeRoutes();
483
+ }
484
+
485
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
486
+ main().catch((error: unknown) => {
487
+ console.error(error instanceof Error ? error.message : String(error));
488
+ process.exitCode = 1;
489
+ });
490
+ }