aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 10:07:23 +0530
committerBobby <[email protected]>2026-04-22 10:07:23 +0530
commitcf691853a93df3e242e229956d03725b89c31fc3 (patch)
tree681f58da4e644b3392334d4ccff0e29939de999a
parent9f19aba9a4775e09ad9c92b173ca876c5157556d (diff)
downloadhollowdark-cf691853a93df3e242e229956d03725b89c31fc3.tar.xz
hollowdark-cf691853a93df3e242e229956d03725b89c31fc3.zip
Add the content pipeline and the Mirror Coast region as the first authored sample
-rw-r--r--.gitignore3
-rw-r--r--content-system/loader/loader.ts83
-rw-r--r--content-system/manifest/schema.ts35
-rw-r--r--content-system/regions/schema.ts117
-rw-r--r--content-system/registry/registry.ts91
-rw-r--r--content/world/cities/avenford.yaml7
-rw-r--r--content/world/cities/kellisport.yaml7
-rw-r--r--content/world/cities/lightwater.yaml7
-rw-r--r--content/world/institutions/halfire_press.yaml7
-rw-r--r--content/world/institutions/lightwater_university.yaml7
-rw-r--r--content/world/regions/mirror_coast.yaml45
-rw-r--r--package.json7
-rw-r--r--pnpm-lock.yaml350
-rw-r--r--scripts/compile-content.ts113
14 files changed, 857 insertions, 22 deletions
diff --git a/.gitignore b/.gitignore
index 6fe63e5..1813beb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@ build/
dist/
built/
+static/content/
+static/content-manifest.json
+
.env
.env.local
.env.*.local
diff --git a/content-system/loader/loader.ts b/content-system/loader/loader.ts
new file mode 100644
index 0000000..97a6c0f
--- /dev/null
+++ b/content-system/loader/loader.ts
@@ -0,0 +1,83 @@
+import type { ContentManifest, ChunkPayload } from '@hollowdark/content-system/manifest/schema'
+import { HollowdarkContentCache } from '@hollowdark/persistence/db'
+import type { JsonValue } from '@hollowdark/utils/types/json'
+
+let cacheInstance: HollowdarkContentCache | null = null
+
+function cache(): HollowdarkContentCache {
+ if (cacheInstance === null) {
+ cacheInstance = new HollowdarkContentCache()
+ }
+ return cacheInstance
+}
+
+const MANIFEST_CACHE_KEY = 'latest'
+
+/**
+ * Fetch the content manifest from static hosting. Falls back to the last
+ * cached manifest when the network request fails — the game works fully
+ * offline once the first download has succeeded.
+ */
+export async function fetchManifest(baseUrl: string): Promise<ContentManifest> {
+ const url = `${baseUrl}/content-manifest.json`
+ try {
+ const response = await fetch(url, { cache: 'no-cache' })
+ if (!response.ok) throw new Error(`manifest ${response.status}`)
+ const live = (await response.json()) as ContentManifest
+ await cache().manifest.put({
+ id: MANIFEST_CACHE_KEY,
+ contentVersion: live.content_version,
+ appVersion: live.app_version,
+ generatedAt: live.generated_at,
+ payload: live as unknown as JsonValue
+ })
+ return live
+ } catch {
+ const cached = await cache().manifest.get(MANIFEST_CACHE_KEY)
+ if (cached === undefined) {
+ throw new Error('Content manifest unavailable and no cached copy exists.')
+ }
+ return cached.payload as unknown as ContentManifest
+ }
+}
+
+/**
+ * Load a single chunk by id, using the manifest to resolve the hashed
+ * URL. Served from the IndexedDB cache whenever the cached hash matches
+ * the manifest's; refetched otherwise. Falls back to the cached payload
+ * on network failure so gameplay continues offline.
+ */
+export async function loadChunk(
+ chunkId: string,
+ manifest: ContentManifest,
+ baseUrl: string
+): Promise<ChunkPayload> {
+ const entry = manifest.chunks[chunkId]
+ if (entry === undefined) {
+ throw new Error(`Content chunk not listed in manifest: ${chunkId}`)
+ }
+
+ const cached = await cache().content.get(chunkId)
+ if (cached !== undefined && cached.hash === entry.hash) {
+ return { chunkId, data: cached.data, version: cached.version, hash: cached.hash }
+ }
+
+ try {
+ const response = await fetch(`${baseUrl}${entry.url}`)
+ if (!response.ok) throw new Error(`chunk ${response.status}`)
+ const data = (await response.json()) as JsonValue
+ await cache().content.put({
+ chunkId,
+ version: entry.version,
+ hash: entry.hash,
+ data,
+ fetchedAt: new Date().toISOString()
+ })
+ return { chunkId, data, version: entry.version, hash: entry.hash }
+ } catch (err) {
+ if (cached !== undefined) {
+ return { chunkId, data: cached.data, version: cached.version, hash: cached.hash }
+ }
+ throw err
+ }
+}
diff --git a/content-system/manifest/schema.ts b/content-system/manifest/schema.ts
new file mode 100644
index 0000000..07eb76f
--- /dev/null
+++ b/content-system/manifest/schema.ts
@@ -0,0 +1,35 @@
+import type { JsonValue } from '@hollowdark/utils/types/json'
+
+/**
+ * A single entry in the content manifest — one compiled chunk served
+ * from static hosting. `url` is relative to the app base path.
+ */
+export interface ManifestEntry {
+ readonly version: string
+ readonly hash: string
+ readonly url: string
+ readonly size_bytes: number
+}
+
+/**
+ * The content manifest — an index of every compiled content chunk the
+ * client might need. Fetched once at session start; diffed against the
+ * cached copy to decide which chunks must be re-downloaded.
+ */
+export interface ContentManifest {
+ readonly content_version: string
+ readonly app_version: string
+ readonly generated_at: string
+ readonly chunks: Readonly<Record<string, ManifestEntry>>
+}
+
+/** Chunk ids are stable, slash-separated, human-readable. */
+export type ChunkId = string
+
+/** A raw chunk payload as received from the network. */
+export interface ChunkPayload {
+ readonly chunkId: ChunkId
+ readonly data: JsonValue
+ readonly version: string
+ readonly hash: string
+}
diff --git a/content-system/regions/schema.ts b/content-system/regions/schema.ts
new file mode 100644
index 0000000..6d053a5
--- /dev/null
+++ b/content-system/regions/schema.ts
@@ -0,0 +1,117 @@
+import type { Brand } from '@hollowdark/utils/types/brand'
+
+/** Stable slug identifying a region across versions. */
+export type RegionId = Brand<string, 'RegionId'>
+
+/** Stable slug identifying a city across versions. */
+export type CityId = Brand<string, 'CityId'>
+
+/** Stable slug identifying an institution across versions. */
+export type InstitutionContentId = Brand<string, 'InstitutionContentId'>
+
+/** Dominant economic sector categories used across the world's regions. */
+export type EconomicSector =
+ | 'agriculture'
+ | 'publishing'
+ | 'education'
+ | 'finance'
+ | 'government'
+ | 'industry'
+ | 'logistics'
+ | 'mining'
+ | 'oil_and_gas'
+ | 'renewable_energy'
+ | 'services'
+ | 'shipping'
+ | 'technology'
+ | 'timber'
+ | 'tourism'
+ | 'trades'
+ | 'entertainment'
+ | 'fishing'
+ | 'retail'
+ | 'design'
+ | 'construction'
+ | 'healthcare'
+ | 'transportation'
+
+/** Coarse measure of income disparity in a region. */
+export type Inequality = 'flat' | 'moderate' | 'high' | 'extreme'
+
+/**
+ * Share of the population affiliated, nominally or actively, with each of
+ * the three main religions plus secular/unaffiliated. Values in [0, 1],
+ * sum ~= 1 per region.
+ */
+export interface ReligiousComposition {
+ readonly covenant: number
+ readonly old_faith: number
+ readonly quiet_path: number
+ readonly secular: number
+}
+
+/** Climate texture — prose for scenes, plus rough numeric anchors. */
+export interface RegionClimate {
+ readonly summary: string
+ readonly temperature_range_c: readonly [number, number]
+ readonly annual_rainfall_mm: number
+}
+
+/** The role a city plays relative to its region. */
+export type CityRole =
+ | 'metropolis'
+ | 'port'
+ | 'university_town'
+ | 'industrial_town'
+ | 'capital'
+ | 'agricultural_hub'
+ | 'resort'
+ | 'mining_town'
+ | 'frontier_town'
+ | 'tourist'
+ | 'mill_town'
+
+/** Region-level content source, one YAML file per region. */
+export interface RegionContent {
+ readonly id: RegionId
+ readonly display_name: string
+ readonly population_weight: number
+ readonly climate: RegionClimate
+ readonly economy: {
+ readonly summary: string
+ readonly dominant_sectors: readonly EconomicSector[]
+ readonly inequality: Inequality
+ }
+ readonly culture: {
+ readonly summary: string
+ readonly texture: string
+ }
+ readonly class_texture: string
+ readonly religion: ReligiousComposition
+ readonly city_ids: readonly CityId[]
+ readonly institution_ids: readonly InstitutionContentId[]
+ readonly stereotypes: {
+ readonly held_by_others: string
+ readonly self_image: string
+ }
+}
+
+/** City-level content source, one YAML file per city. */
+export interface CityContent {
+ readonly id: CityId
+ readonly region_id: RegionId
+ readonly display_name: string
+ readonly population: number
+ readonly role: CityRole
+ readonly character: string
+}
+
+/** Institution-level content source, one YAML file per institution. */
+export interface InstitutionContent {
+ readonly id: InstitutionContentId
+ readonly region_id: RegionId
+ readonly city_id: CityId
+ readonly display_name: string
+ readonly kind: 'publisher' | 'university' | 'hospital' | 'newspaper' | 'employer' | 'religious' | 'government'
+ readonly character: string
+}
diff --git a/content-system/registry/registry.ts b/content-system/registry/registry.ts
new file mode 100644
index 0000000..38f0a91
--- /dev/null
+++ b/content-system/registry/registry.ts
@@ -0,0 +1,91 @@
+import type {
+ CityContent,
+ CityId,
+ InstitutionContent,
+ InstitutionContentId,
+ RegionContent,
+ RegionId
+} from '@hollowdark/content-system/regions/schema'
+import type { ContentManifest } from '@hollowdark/content-system/manifest/schema'
+import { loadChunk } from '@hollowdark/content-system/loader/loader'
+
+/**
+ * The in-memory content registry. Populated once at session start by
+ * walking the manifest and loading every chunk into typed maps. All
+ * simulation-time reads go through here — synchronous, typed, fast.
+ */
+export class ContentRegistry {
+ readonly #regions = new Map<RegionId, RegionContent>()
+ readonly #cities = new Map<CityId, CityContent>()
+ readonly #institutions = new Map<InstitutionContentId, InstitutionContent>()
+
+ get regions(): ReadonlyMap<RegionId, RegionContent> {
+ return this.#regions
+ }
+
+ get cities(): ReadonlyMap<CityId, CityContent> {
+ return this.#cities
+ }
+
+ get institutions(): ReadonlyMap<InstitutionContentId, InstitutionContent> {
+ return this.#institutions
+ }
+
+ addRegion(region: RegionContent): void {
+ this.#regions.set(region.id, region)
+ }
+
+ addCity(city: CityContent): void {
+ this.#cities.set(city.id, city)
+ }
+
+ addInstitution(institution: InstitutionContent): void {
+ this.#institutions.set(institution.id, institution)
+ }
+
+ region(id: RegionId): RegionContent {
+ const value = this.#regions.get(id)
+ if (value === undefined) throw new Error(`Unknown region: ${id}`)
+ return value
+ }
+
+ city(id: CityId): CityContent {
+ const value = this.#cities.get(id)
+ if (value === undefined) throw new Error(`Unknown city: ${id}`)
+ return value
+ }
+
+ institution(id: InstitutionContentId): InstitutionContent {
+ const value = this.#institutions.get(id)
+ if (value === undefined) throw new Error(`Unknown institution: ${id}`)
+ return value
+ }
+}
+
+/**
+ * Populate a fresh registry from the supplied manifest. Fetches every
+ * chunk whose id begins with a world-content prefix; ignores unknown
+ * prefixes so the registry can grow without breaking older clients.
+ */
+export async function populateFromManifest(
+ manifest: ContentManifest,
+ baseUrl: string
+): Promise<ContentRegistry> {
+ const registry = new ContentRegistry()
+
+ const entries = Object.entries(manifest.chunks)
+ await Promise.all(
+ entries.map(async ([chunkId]) => {
+ const { data } = await loadChunk(chunkId, manifest, baseUrl)
+ if (chunkId.startsWith('world/regions/')) {
+ registry.addRegion(data as unknown as RegionContent)
+ } else if (chunkId.startsWith('world/cities/')) {
+ registry.addCity(data as unknown as CityContent)
+ } else if (chunkId.startsWith('world/institutions/')) {
+ registry.addInstitution(data as unknown as InstitutionContent)
+ }
+ })
+ )
+
+ return registry
+}
diff --git a/content/world/cities/avenford.yaml b/content/world/cities/avenford.yaml
new file mode 100644
index 0000000..9ef17e4
--- /dev/null
+++ b/content/world/cities/avenford.yaml
@@ -0,0 +1,7 @@
+id: avenford
+region_id: mirror_coast
+display_name: Avenford
+population: 340000
+role: metropolis
+character: |
+ The region's metropolis, built on a long harbour that still carries shipping traffic at the north end and turned almost entirely residential at the south. The old quarter around the university and the press row is dense with bookshops, reading rooms, and small studios above shopfronts; the new financial district rises behind it in glass. Rents have been the subject of editorial columns for fifteen years running. The fog comes in most mornings and lifts by midday. The bridges are still the oldest-looking thing in town, cast iron kept painted a sober grey.
diff --git a/content/world/cities/kellisport.yaml b/content/world/cities/kellisport.yaml
new file mode 100644
index 0000000..f465e4e
--- /dev/null
+++ b/content/world/cities/kellisport.yaml
@@ -0,0 +1,7 @@
+id: kellisport
+region_id: mirror_coast
+display_name: Kellisport
+population: 84000
+role: port
+character: |
+ The old working port. Its fortunes rise and fall with the shipping industry and at the moment they are falling quietly. Victorian terraces back onto the docks, and inland the streets run on a grid laid down by the merchants' guilds in the 1800s. A third of the adult population still works the port in some form. The rest work in the adjacent warehouses, the rail depot, the fish market, or the hospitals and schools that kept pace with the population. Houses here are cheap and stay cheap. Bands come out of Kellisport every few years that the Avenford press suddenly cares about.
diff --git a/content/world/cities/lightwater.yaml b/content/world/cities/lightwater.yaml
new file mode 100644
index 0000000..f418bce
--- /dev/null
+++ b/content/world/cities/lightwater.yaml
@@ -0,0 +1,7 @@
+id: lightwater
+region_id: mirror_coast
+display_name: Lightwater
+population: 52000
+role: university_town
+character: |
+ A smaller inland city built around the university, which employs more than a sixth of the working population directly and another sixth indirectly. Stone quadrangles, a cathedral the Quiet Path shares with the Covenant on alternating afternoons, student housing converted from former monasteries, and a ring of newer research buildings on the southern edge. A town that empties for six weeks every summer and fills again in the autumn rain. Two decent bookshops, three good ones, and a river that runs the length of the old quarter.
diff --git a/content/world/institutions/halfire_press.yaml b/content/world/institutions/halfire_press.yaml
new file mode 100644
index 0000000..9121b79
--- /dev/null
+++ b/content/world/institutions/halfire_press.yaml
@@ -0,0 +1,7 @@
+id: halfire_press
+region_id: mirror_coast
+city_id: avenford
+display_name: The Halfire Press
+kind: publisher
+character: |
+ The region's most storied literary publisher. Founded in 1889 and independent through every consolidation wave since. Operates out of three floors of a narrow Avenford townhouse with a street-level reading room open to anyone. Publishes around thirty titles a year — literary fiction, essays, and a small philosophy list kept alive more from principle than profit. Its imprint is recognised across the world and turning down an offer from Halfire is a kind of minor career milestone in itself. Rights to copy-edit are fought over. The editor-in-chief writes a column for the Sunday papers that no one important admits to reading.
diff --git a/content/world/institutions/lightwater_university.yaml b/content/world/institutions/lightwater_university.yaml
new file mode 100644
index 0000000..12e9799
--- /dev/null
+++ b/content/world/institutions/lightwater_university.yaml
@@ -0,0 +1,7 @@
+id: lightwater_university
+region_id: mirror_coast
+city_id: lightwater
+display_name: Lightwater University
+kind: university
+character: |
+ A medium-large university, old by Mirror Coast standards — its oldest college dates to the twelfth century, and its newest to the 1960s. Strong humanities, a well-regarded philosophy department, a literature faculty that runs a steady pipeline into the Avenford press row, and a science faculty that has modernised quietly over the last forty years. The student population sits around eighteen thousand, a third of them from outside the region. Tuition is public and steeply subsidised; competition for entry is sharp. Professors tend to live in the old city and walk to work.
diff --git a/content/world/regions/mirror_coast.yaml b/content/world/regions/mirror_coast.yaml
new file mode 100644
index 0000000..bd843d4
--- /dev/null
+++ b/content/world/regions/mirror_coast.yaml
@@ -0,0 +1,45 @@
+id: mirror_coast
+display_name: Mirror Coast
+population_weight: 0.12
+
+climate:
+ summary: Wet autumns, mild winters, cool summers. Fog off the water for most of the year, and a rainy season that runs long.
+ temperature_range_c: [-2, 24]
+ annual_rainfall_mm: 1180
+
+economy:
+ summary: The intellectual and creative heart of the world. Publishing, universities, design studios, the old shipping trade, and a finance industry that quietly funds most of it.
+ dominant_sectors:
+ - publishing
+ - education
+ - design
+ - shipping
+ - finance
+ inequality: high
+
+culture:
+ summary: Cosmopolitan, literate, liberal-leaning, secularised.
+ texture: |
+ People here read. They argue in cafes about things no one else cares about, they know which essay ran in which magazine last month, and they pretend not to care about the weather while building their lives around it. The region prides itself on being tolerant and is visibly less so about money. Old money keeps quiet; new money is loud; the working class is getting priced out of every neighbourhood that remembers it. Under everything, a gentle fog of self-regard.
+
+class_texture: |
+ Sharp inequality with a polite face. The professional class is comfortable and tired. The working class is hanging on in the old port wards and the inland mill towns. Real poverty, mostly hidden, sits in single-room occupancies above the shops and in the long-term rentals of the old dock neighbourhoods.
+
+religion:
+ covenant: 0.25
+ old_faith: 0.05
+ quiet_path: 0.25
+ secular: 0.45
+
+city_ids:
+ - avenford
+ - kellisport
+ - lightwater
+
+institution_ids:
+ - halfire_press
+ - lightwater_university
+
+stereotypes:
+ held_by_others: Snobs. Pricing everyone out of housing. Rude in restaurants. Read too many books. Actually, pretty nice if you get to know them.
+ self_image: We're sophisticated, literate, and put-upon by the rest of the country's resentment. We built the publishing industry, the universities, the shipping trade. The fog is ours.
diff --git a/package.json b/package.json
index 534b382..11b530c 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,10 @@
"node": ">=20.11"
},
"scripts": {
+ "compile-content": "tsx scripts/compile-content.ts",
+ "predev": "pnpm run compile-content",
"dev": "vite dev",
+ "prebuild": "pnpm run compile-content",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -39,9 +42,11 @@
"svelte": "^5.55.4",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.6.0",
+ "tsx": "^4.21.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.9",
- "vitest": "^4.1.5"
+ "vitest": "^4.1.5",
+ "yaml": "^2.8.3"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 09fed39..427e284 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,13 +17,13 @@ importers:
version: 10.0.1([email protected])
'@sveltejs/adapter-static':
specifier: ^3.0.10
'@sveltejs/kit':
specifier: ^2.57.1
'@sveltejs/vite-plugin-svelte':
specifier: ^7.0.0
- version: 7.0.0([email protected](@typescript-eslint/[email protected]))([email protected](@types/[email protected]))
'@types/node':
specifier: ^25.6.0
version: 25.6.0
@@ -54,6 +54,9 @@ importers:
svelte-eslint-parser:
specifier: ^1.6.0
version: 1.6.0([email protected](@typescript-eslint/[email protected]))
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -62,10 +65,13 @@ importers:
vite:
specifier: ^8.0.9
- version: 8.0.9(@types/[email protected])
vitest:
specifier: ^4.1.5
- version: 4.1.5(@types/[email protected])([email protected](@types/[email protected]))
+ yaml:
+ specifier: ^2.8.3
+ version: 2.8.3
packages:
@@ -78,6 +84,162 @@ packages:
'@emnapi/[email protected]':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/[email protected]':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@eslint-community/[email protected]':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -504,6 +666,11 @@ packages:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -634,6 +801,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -894,6 +1064,9 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -985,6 +1158,11 @@ packages:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1120,6 +1298,11 @@ packages:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
+ resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1145,6 +1328,84 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
'@eslint-community/[email protected]([email protected])':
dependencies:
eslint: 10.2.1
@@ -1282,15 +1543,15 @@ snapshots:
dependencies:
acorn: 8.16.0
dependencies:
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9([email protected])
- '@sveltejs/vite-plugin-svelte': 7.0.0([email protected](@typescript-eslint/[email protected]))([email protected](@types/[email protected]))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@@ -1302,18 +1563,18 @@ snapshots:
set-cookie-parser: 3.1.0
sirv: 3.0.2
svelte: 5.55.4(@typescript-eslint/[email protected])
- vite: 8.0.9(@types/[email protected])
optionalDependencies:
typescript: 6.0.3
dependencies:
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.55.4(@typescript-eslint/[email protected])
- vite: 8.0.9(@types/[email protected])
- vitefu: 1.1.3([email protected](@types/[email protected]))
dependencies:
@@ -1441,13 +1702,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.9(@types/[email protected])
'@vitest/[email protected]':
dependencies:
@@ -1534,6 +1795,35 @@ snapshots:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
+
@@ -1678,6 +1968,10 @@ snapshots:
optional: true
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
dependencies:
is-glob: 4.0.3
@@ -1866,6 +2160,8 @@ snapshots:
+
dependencies:
'@oxc-project/types': 0.126.0
@@ -1980,6 +2276,13 @@ snapshots:
optional: true
+ dependencies:
+ esbuild: 0.27.7
+ get-tsconfig: 4.14.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
dependencies:
prelude-ls: 1.2.1
@@ -2005,7 +2308,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -2014,16 +2317,19 @@ snapshots:
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.6.0
+ esbuild: 0.27.7
fsevents: 2.3.3
+ tsx: 4.21.0
+ yaml: 2.8.3
optionalDependencies:
- vite: 8.0.9(@types/[email protected])
dependencies:
'@vitest/expect': 4.1.5
- '@vitest/mocker': 4.1.5([email protected](@types/[email protected]))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -2040,7 +2346,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vite: 8.0.9(@types/[email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
@@ -2060,6 +2366,8 @@ snapshots:
+
diff --git a/scripts/compile-content.ts b/scripts/compile-content.ts
new file mode 100644
index 0000000..b3e8989
--- /dev/null
+++ b/scripts/compile-content.ts
@@ -0,0 +1,113 @@
+import { createHash } from 'node:crypto'
+import {
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync
+} from 'node:fs'
+import { dirname, join, relative, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { parse as parseYaml } from 'yaml'
+
+import type {
+ ContentManifest,
+ ManifestEntry
+} from '@hollowdark/content-system/manifest/schema'
+
+const HERE = dirname(fileURLToPath(import.meta.url))
+const PROJECT_ROOT = join(HERE, '..')
+const CONTENT_DIR = join(PROJECT_ROOT, 'content')
+const OUT_CONTENT_DIR = join(PROJECT_ROOT, 'static', 'content')
+const OUT_MANIFEST_PATH = join(PROJECT_ROOT, 'static', 'content-manifest.json')
+
+function readPackageVersion(): string {
+ const pkg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')) as {
+ version: string
+ }
+ return pkg.version
+}
+
+function walkYaml(dir: string): readonly string[] {
+ const out: string[] = []
+ const walk = (current: string): void => {
+ for (const entry of readdirSync(current)) {
+ const full = join(current, entry)
+ const stat = statSync(full)
+ if (stat.isDirectory()) {
+ walk(full)
+ } else if (stat.isFile() && entry.endsWith('.yaml')) {
+ out.push(full)
+ }
+ }
+ }
+ walk(dir)
+ return out
+}
+
+function toChunkId(absPath: string): string {
+ const rel = relative(CONTENT_DIR, absPath)
+ const withoutExt = rel.slice(0, -'.yaml'.length)
+ return withoutExt.split(sep).join('/')
+}
+
+function shortHash(bytes: Buffer | string): string {
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16)
+}
+
+function writeChunk(chunkId: string, data: unknown, version: string): ManifestEntry {
+ const jsonBody = JSON.stringify(data)
+ const hash = shortHash(jsonBody)
+ const segments = chunkId.split('/')
+ const leaf = segments[segments.length - 1]
+ if (leaf === undefined) {
+ throw new Error(`Empty chunk id from compilation input`)
+ }
+ const parentSegments = segments.slice(0, -1)
+ const finalName = `${leaf}.v${version}.${hash}.json`
+ const outPath = join(OUT_CONTENT_DIR, ...parentSegments, finalName)
+ mkdirSync(dirname(outPath), { recursive: true })
+ writeFileSync(outPath, jsonBody, 'utf8')
+
+ const urlPath = [...parentSegments, finalName].join('/')
+ return {
+ version,
+ hash,
+ url: `/content/${urlPath}`,
+ size_bytes: Buffer.byteLength(jsonBody, 'utf8')
+ }
+}
+
+function compile(): void {
+ const version = readPackageVersion()
+
+ rmSync(OUT_CONTENT_DIR, { recursive: true, force: true })
+ mkdirSync(OUT_CONTENT_DIR, { recursive: true })
+
+ const yamlFiles = walkYaml(CONTENT_DIR)
+ const chunks: Record<string, ManifestEntry> = {}
+
+ for (const absPath of yamlFiles) {
+ const raw = readFileSync(absPath, 'utf8')
+ const parsed = parseYaml(raw) as unknown
+ const chunkId = toChunkId(absPath)
+ chunks[chunkId] = writeChunk(chunkId, parsed, version)
+ }
+
+ const manifest: ContentManifest = {
+ content_version: version,
+ app_version: version,
+ generated_at: new Date().toISOString(),
+ chunks
+ }
+
+ mkdirSync(dirname(OUT_MANIFEST_PATH), { recursive: true })
+ writeFileSync(OUT_MANIFEST_PATH, JSON.stringify(manifest, null, 2), 'utf8')
+
+ const chunkCount = Object.keys(chunks).length
+ process.stdout.write(`Compiled ${chunkCount} content chunk${chunkCount === 1 ? '' : 's'} to ${OUT_CONTENT_DIR}\n`)
+}
+
+compile()