diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 71ea2ac..3b5296d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,7 +1,7 @@ name: Deploy # Push to main → deploy to bitbaum. All logic lives in one place: -# catomean/fleetcrown/.github/workflows/selfhost-deploy.yml +# bitbaum/fleetcrown/.github/workflows/selfhost-deploy.yml on: workflow_dispatch: {} push: @@ -9,7 +9,7 @@ on: jobs: deploy: - uses: catomean/fleetcrown/.github/workflows/selfhost-deploy.yml@main + uses: bitbaum/fleetcrown/.github/workflows/selfhost-deploy.yml@main with: app: substrata secrets: inherit diff --git a/README.md b/README.md index 088ab30..a2f9f93 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ npm run verify # type-check + lint + tests ## Deployment Push to `main`. CD is the shared self-host pipeline -(`catomean/fleetcrown/.github/workflows/selfhost-deploy.yml`), which waits +(`bitbaum/fleetcrown/.github/workflows/selfhost-deploy.yml`), which waits for this commit's CI, builds, rsyncs to bitbaum and health-checks before declaring done. Port and hostname come from `scripts/hetzner/apps.conf` in fleetcrown — the SSOT for what runs on the box. diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index 6e32a60..4373972 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -9,8 +9,8 @@ import { sitePages, } from '@/config/site-content'; import { SITE } from '@/lib/site'; -import { SiteFooter, SiteMasthead } from '@/components/SiteChrome'; -import { SiteSections } from '@/components/SiteSections'; +import Link from 'next/link'; +import { SiteFooter, SiteMasthead, SiteSections } from 'sitekit/react'; interface RouteParams { params: Promise<{ path?: string[] }>; @@ -45,7 +45,7 @@ export default async function Page({ params }: RouteParams) { return (
- +
{!pageRendersOwnHeader(page) && ( diff --git a/app/globals.css b/app/globals.css index db63690..3be9567 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,7 @@ @import 'tailwindcss'; +/* The renderers live in the sitekit package now; Tailwind must scan them or + their semantic classes are never emitted. */ +@source '../node_modules/sitekit/dist'; /** * Substrata's design tokens — the SSOT for every visual decision. diff --git a/components/SiteChrome.tsx b/components/SiteChrome.tsx deleted file mode 100644 index c59fc87..0000000 --- a/components/SiteChrome.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Substrata's masthead and footer. - * - * The masthead is a rule, not a bar. It sticks so the section nav stays - * reachable on a long research page, but it carries no shadow, no fill beyond - * the page's own surface, and no logo — a hairline and a wordmark are enough, - * and anything heavier competes with the document. - * - * There is no "hosted on" line. Substrata runs on a subdomain of a domain its - * founder happens to own; that is an address, not an affiliation, and a - * research firm's footer has no business advertising someone else's product. - */ - -import React from 'react'; -import Link from 'next/link'; -import { href, SITE } from '@/lib/site'; -import type { SiteChrome as SiteChromeSpec, SiteNavItem } from '@/config/site-content'; -import { SiteNav } from './SiteNav'; - -interface Props { - chrome: SiteChromeSpec; - navItems: SiteNavItem[]; - currentPath: string; -} - -export function SiteMasthead({ chrome, navItems, currentPath }: Props) { - return ( -
-
- {/* One row at every width. Wrapping the nav onto a second line made a - sticky masthead eat a third of a phone screen, so on narrow - viewports the nav scrolls sideways instead. */} -
- - - {chrome.name} - - - - -
-
-
- ); -} - -export function SiteFooter({ chrome }: { chrome: SiteChromeSpec }) { - return ( -
-
-

{chrome.footerNote}

-
- {SITE.host} - © {SITE.name} -
-
-
- ); -} diff --git a/components/SiteNav.tsx b/components/SiteNav.tsx deleted file mode 100644 index 7122493..0000000 --- a/components/SiteNav.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -/** - * The masthead's section nav. - * - * A client component for one reason: with eight sections the nav scrolls - * sideways on a phone, and the active item is frequently off-screen at rest — - * so a visitor arriving on a deep page sees a row of links with no indication - * that any of them is the page they are on. Scrolling it into view on mount - * fixes that, and there is no CSS-only way to do it. - * - * Everything else about the masthead stays server-rendered. - */ - -import React, { useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { href } from '@/lib/site'; -import type { SiteNavItem } from '@/config/site-content'; - -interface Props { - /** - * Deliberately NOT `SitePage[]`. See `SiteNavItem` — everything passed to a - * client component is serialised into the payload of every page it renders on. - */ - items: SiteNavItem[]; - currentPath: string; -} - -export function SiteNav({ items, currentPath }: Props) { - const navRef = useRef(null); - - useEffect(() => { - const active = navRef.current?.querySelector('[aria-current="page"]'); - // `nearest` keeps the page itself still — `center` would scroll the whole - // document to the top of the masthead on every navigation. - active?.scrollIntoView({ inline: 'nearest', block: 'nearest' }); - }, [currentPath]); - - return ( - - ); -} diff --git a/components/SiteSections.tsx b/components/SiteSections.tsx deleted file mode 100644 index d416810..0000000 --- a/components/SiteSections.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Dispatches a page's sections to their renderers, and numbers them. - * - * One renderer for every hosted site is the point: it is what stops fifty - * customer websites becoming fifty stylesheets, and it is why a site inherits - * typography, dark mode and reading measure without its owner thinking about - * any of them. Section shapes are the closed set in `src/config/site-content.ts`. - * - * Numbering counts only sections that carry a heading, so an unheaded lead - * paragraph or a bare table does not consume a number and leave a gap in the - * sequence a reader can see. - */ - -import React from 'react'; -import type { SiteSection } from '@/config/site-content'; -import { HeroSection } from './sections/HeroSection'; -import { MeterSection, StatsSection } from './sections/FigureSections'; -import { CardsSection, DefinitionsSection, ProseSection } from './sections/ProseSections'; -import { IndexSection, TableSection } from './sections/DataSections'; - -/** True when a section shows a heading, and therefore takes the next number. */ -function isNumbered(section: SiteSection): boolean { - return section.kind !== 'hero' && section.kind !== 'table' && Boolean(section.heading); -} - -export function SiteSections({ sections }: { sections: SiteSection[] }) { - let counter = 0; - - return ( -
- {sections.map((section, key) => { - const index = isNumbered(section) ? ++counter : undefined; - - switch (section.kind) { - case 'hero': - return ; - case 'prose': - return ; - case 'stats': - return ; - case 'meter': - return ; - case 'cards': - return ; - case 'definitions': - return ; - case 'index': - return ; - case 'table': - return ; - default: - return null; - } - })} -
- ); -} diff --git a/components/sections/DataSections.tsx b/components/sections/DataSections.tsx deleted file mode 100644 index 27ab768..0000000 --- a/components/sections/DataSections.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Sections that present the research itself: the jump index and the tables. - * - * These are the instrument. Codes, counts and statuses are set in mono so a - * reader can scan a column; the status cell carries a dot because "Unverified - * lead" and "Sourced" are the two words on this site a reader must never - * skim past. - */ - -import React from 'react'; -import { Blurb, SectionBody, SectionHeading } from './Primitives'; -import type { SiteSection } from '@/config/site-content'; - -/** Status keyword → dot colour. Anything unrecognised stays neutral. */ -const STATUS_DOT: Record = { - sourced: 'bg-status-positive', - 'unverified lead': 'bg-status-warning', - taken: 'bg-fg-muted', - done: 'bg-status-positive', - 'in progress': 'bg-status-warning', - 'not started': 'bg-fg-muted', - // Scarcity grades. Red reads as "constrained", green as "no constraint here" — - // the opposite of a risk dashboard, and correct for this page. - chokepoint: 'bg-status-negative', - concentrated: 'bg-status-warning', - competitive: 'bg-status-positive', -}; - -export function IndexSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - return ( -
- {section.heading && {section.heading}} - {section.blurb &&
{{section.blurb}}
} - -
    - {section.entries.map((entry, i) => ( -
  1. - - - {String(i + 1).padStart(2, '0')} - - - {entry.label} - - {entry.meta && ( - {entry.meta} - )} - -
  2. - ))} -
-
-
- ); -} - -export function TableSection({ section }: { section: Extract }) { - const mono = new Set(section.monoColumns ?? []); - - // Fixed layout with declared widths, because this page stacks fifteen tables - // with the same four columns. Auto layout sizes each one to its own longest - // cell, so "Malaysia Smelting Corporation" in one table shifts every column - // out of line with the table above it. Aligned columns are what make the set - // read as one instrument rather than fifteen separate exhibits. The first - // column carries the names and takes a third; the rest divide the remainder. - const restWidth = section.columns.length > 1 ? 66 / (section.columns.length - 1) : 100; - - return ( -
- {section.heading && ( -

{section.heading}

- )} - {section.blurb && ( -

- {section.blurb} -

- )} -
- - - {section.columns.map((column, i) => ( - - ))} - - - - {section.columns.map(column => ( - - ))} - - - - {section.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => { - const isStatus = cellIndex === section.statusColumn; - const dot = isStatus ? STATUS_DOT[cell.toLowerCase()] : undefined; - return ( - - ); - })} - - ))} - -
- {column} -
- {dot ? ( - - - {cell} - - ) : ( - cell - )} -
-
- {section.note &&

{section.note}

} -
- ); -} diff --git a/components/sections/FigureSections.tsx b/components/sections/FigureSections.tsx deleted file mode 100644 index a505481..0000000 --- a/components/sections/FigureSections.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Sections that render a number. - * - * Both use tabular mono figures so digits line up column to column — the - * difference between a page that looks measured and one that looks typed. - */ - -import React from 'react'; -import { SectionBody, SectionHeading } from './Primitives'; -import type { SiteSection } from '@/config/site-content'; - -export function StatsSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - return ( -
- {section.heading && {section.heading}} -
- {section.stats.map(stat => ( -
-
- {stat.label} -
-
- {stat.value} -
- {stat.note &&

{stat.note}

} -
- ))} -
-
- ); -} - -/** - * One number, drawn. - * - * The bar is intentionally literal: when nothing is sourced it renders empty, - * because an empty bar is the honest picture of an unfinished research phase. - * Anything that dressed a 0% up as progress would be the page lying. - */ -export function MeterSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - const percent = section.of > 0 ? Math.round((section.value / section.of) * 100) : 0; - - return ( -
- {section.heading && {section.heading}} - -
- - {section.label} - - {percent}% -
-

- {section.value} - / {section.of} -

-
-
-
- {section.caption && ( -

- {section.caption} -

- )} - -
- ); -} diff --git a/components/sections/HeroSection.tsx b/components/sections/HeroSection.tsx deleted file mode 100644 index 5915071..0000000 --- a/components/sections/HeroSection.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The opening of a page that has one. - * - * An eyebrow, one display statement, and the lead — the shape a serious - * publication uses, and deliberately not a marketing hero: no gradient, no - * illustration, no button. The claim is the design. - */ - -import React from 'react'; -import type { SiteSection } from '@/config/site-content'; - -export function HeroSection({ section }: { section: Extract }) { - return ( -
- {section.eyebrow && ( -

- {section.eyebrow} -

- )} -

- {section.statement} -

-
- {section.lead.map((paragraph, index) => ( -

- {paragraph} -

- ))} -
-
- ); -} diff --git a/components/sections/Primitives.tsx b/components/sections/Primitives.tsx deleted file mode 100644 index c1be1a9..0000000 --- a/components/sections/Primitives.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Shared type primitives for hosted-site sections. - * - * The whole visual system of a hosted site lives in these three components plus - * the section files beside them. Typography does the work: Space Grotesk for - * display, IBM Plex Mono for anything a reader might compare (codes, counts, - * prices, statuses), Inter for prose at a reading measure. Colour is reserved — - * monochrome surfaces, the warm accent only for the current position and one - * meter fill, status colours only on actual status. - */ - -import React from 'react'; - -/** - * A numbered section heading. Research houses number their sections; it makes a - * long page navigable and signals that the document has a structure rather than - * being a stack of blocks. - */ -export function SectionHeading({ index, children }: { index?: number; children: React.ReactNode }) { - return ( -
- {index !== undefined && ( - - {String(index).padStart(2, '0')} - - )} -

- {children} -

-
- ); -} - -/** Standfirst under a heading. Kept at measure — it is prose, not layout. */ -export function Blurb({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -/** - * Indents a section's body to sit under the heading text rather than under its - * number. Small thing; it is what makes the numbering read as a margin note - * instead of a bullet. - */ -export function SectionBody({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/components/sections/ProseSections.tsx b/components/sections/ProseSections.tsx deleted file mode 100644 index 91156f0..0000000 --- a/components/sections/ProseSections.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Sections made of words: running prose, top-ruled cards, and definitions. - * - * Cards are ruled rather than boxed. A grid of bordered boxes reads as a - * product page; a grid of hairline-topped columns reads as a document, which - * is what a research firm is publishing. - */ - -import React from 'react'; -import { Blurb, SectionBody, SectionHeading } from './Primitives'; -import type { SiteSection } from '@/config/site-content'; - -export function ProseSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - return ( -
- {section.heading && {section.heading}} -
-
- {section.paragraphs.map((paragraph, i) => ( -

- {paragraph} -

- ))} -
-
-
- ); -} - -export function CardsSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - const columns = section.columns === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'; - return ( -
- {section.heading && {section.heading}} - {section.blurb &&
{{section.blurb}}
} - -
- {section.cards.map(card => ( -
-

{card.title}

-

{card.body}

- {card.meta && ( -

{card.meta}

- )} -
- ))} -
-
-
- ); -} - -export function DefinitionsSection({ - section, - index, -}: { - section: Extract; - index?: number; -}) { - return ( -
- {section.heading && {section.heading}} - {section.blurb &&
{{section.blurb}}
} - -
- {section.items.map(item => ( -
-
{item.term}
-
- {item.detail} -
-
- ))} -
-
-
- ); -} diff --git a/config/site-content.ts b/config/site-content.ts index 515a98f..ed537b4 100644 --- a/config/site-content.ts +++ b/config/site-content.ts @@ -1,125 +1,33 @@ /** - * Hosted-site content model. + * Hosted-site content model — now owned by `sitekit`. * - * A hosted site is a list of pages, and a page is a list of sections drawn - * from a small closed set of shapes. That constraint is the design: it means a - * profile can produce a whole website out of the structured data it already - * has, without anybody hand-writing JSX per customer, and it means every - * hosted site inherits typography, spacing and dark mode from one renderer - * instead of drifting into fifty bespoke stylesheets. + * The section union, page/chrome shapes and the helpers were extracted to the + * shared package (github:bitbaum/sitekit) on 2026-08-28; substrata is its + * reference consumer. This file keeps two things only: the re-exports, so the + * rest of the app keeps one import path, and the wiring that says WHICH pages + * this site has. Content itself stays in `site-substrata.ts`. * - * Adding an ordinary site costs NO code: `site-profile.ts` renders any group - * from the profile it already has, and the database decides which groups are - * published. A builder is written only when a site's content is genuinely not - * profile-shaped — Substrata's lives in `site-substrata.ts` and is built - * entirely from the config the profile already needed, so nothing on that - * website is authored twice either. - * - * Created: 2026-08-26 + * Extending the section set happens in sitekit — a new kind lands there with + * its schema, renderer and tests, and every generated site gets it on the + * next version bump. That is the point of the extraction. */ +export type { + SiteStat, + SiteCard, + SiteDefinition, + SiteIndexEntry, + SiteSection, + SitePage, + SiteChrome, + SiteNavItem, +} from 'sitekit'; -// ===================================================================== -// SECTIONS -// ===================================================================== - -/** A stat worth putting at the top of a page. */ -export interface SiteStat { - label: string; - value: string; - note?: string; -} - -export interface SiteCard { - title: string; - body: string; - /** Short trailing line — a price, a status, a jurisdiction. */ - meta?: string; -} - -export interface SiteDefinition { - term: string; - detail: string; -} - -export interface SiteIndexEntry { - label: string; - /** Small trailing figure — a count, a desk name. Rendered in mono. */ - meta?: string; - /** Fragment this entry jumps to; must match a section's `anchor`. */ - anchor: string; -} - -export type SiteSection = - /** - * Opens a page in place of the standard title block. An eyebrow, one display - * statement, and the lead. Only ever the FIRST section of a page — the shell - * checks for it and suppresses its own header so the two cannot both render. - */ - | { kind: 'hero'; eyebrow?: string; statement: string; lead: string[] } - | { kind: 'prose'; heading?: string; paragraphs: string[] } - | { kind: 'stats'; heading?: string; stats: SiteStat[] } - /** - * One number that deserves a picture. Used for research coverage, where the - * gap between `value` and `of` IS the message — an empty bar is the honest - * rendering of "nothing sourced yet", and it should look empty. - */ - | { kind: 'meter'; heading?: string; label: string; value: number; of: number; caption?: string } - | { kind: 'cards'; heading?: string; blurb?: string; columns?: 2 | 3; cards: SiteCard[] } - | { kind: 'definitions'; heading?: string; blurb?: string; items: SiteDefinition[] } - /** Jump list for a long page. Without one, fifteen tables is a scroll, not a document. */ - | { kind: 'index'; heading?: string; blurb?: string; entries: SiteIndexEntry[] } - | { - kind: 'table'; - heading?: string; - blurb?: string; - /** Fragment id, so an `index` entry can link straight here. */ - anchor?: string; - columns: string[]; - rows: string[][]; - /** Column indices rendered in mono — codes, figures, statuses. */ - monoColumns?: number[]; - /** Column index whose cell text is also a status keyword to dot-colour. */ - statusColumn?: number; - note?: string; - }; - -// ===================================================================== -// PAGES -// ===================================================================== - -export interface SitePage { - /** '' is the home page; otherwise a single path segment, e.g. 'map'. */ - path: string; - /** Label in the site's own navigation. Omit to keep a page out of the nav. */ - navLabel?: string; - /** and the page's h1. */ - title: string; - /** One line under the h1. */ - intro?: string; - sections: SiteSection[]; -} - -/** Everything the site chrome needs: the masthead, the nav, the footer. */ -export interface SiteChrome { - name: string; - tagline: string; - /** Line in the footer — who runs this and under what rules. */ - footerNote: string; -} - -// ===================================================================== -// PAGES -// ===================================================================== +export { siteNavItems, pageRendersOwnHeader, sitePageAt } from 'sitekit'; +import type { SiteChrome, SitePage } from 'sitekit'; import { substrataSiteChrome, substrataSitePages } from './site-substrata'; -/** Nav entry: a label and where it goes, nothing more. */ -export interface SiteNavItem { - path: string; - label: string; -} - export function sitePages(): SitePage[] { return substrataSitePages(); } @@ -127,24 +35,3 @@ export function sitePages(): SitePage[] { export function siteChrome(): SiteChrome { return substrataSiteChrome(); } - -/** Nav entries in page order. Pages without a `navLabel` are omitted. */ -export function siteNavItems(pages: SitePage[]): SiteNavItem[] { - return pages - .filter((page): page is SitePage & { navLabel: string } => Boolean(page.navLabel)) - .map(page => ({ path: page.path, label: page.navLabel })); -} - -/** - * True when the page opens with its own hero, in which case the shell must not - * also print a title block. One rule, checked in one place. - */ -export function pageRendersOwnHeader(page: SitePage): boolean { - return page.sections[0]?.kind === 'hero'; -} - -/** @returns the page at this path, or null. */ -export function sitePageAt(pages: SitePage[], path: string): SitePage | null { - const normalised = path.replace(/^\/+|\/+$/g, ''); - return pages.find(page => page.path === normalised) ?? null; -} diff --git a/config/site-substrata.ts b/config/site-substrata.ts index acee873..debdc20 100644 --- a/config/site-substrata.ts +++ b/config/site-substrata.ts @@ -53,6 +53,7 @@ import { coverageProgress, } from './substrata-coverage'; import type { SiteChrome, SitePage, SiteSection } from './site-content'; +import { SITE } from '../lib/site'; const ROLE_LABEL: Record<string, string> = Object.fromEntries( PRODUCER_ROLES.map(role => [role.id, role.label]) @@ -62,6 +63,7 @@ export function substrataSiteChrome(): SiteChrome { return { name: COMPANY.name, tagline: COMPANY.tagline, + host: SITE.host, footerNote: `${COMPANY.name} publishes research. It does not trade, broker or quote, holds no ` + 'position in anything it covers, and nothing here is an offer or investment advice. ' + diff --git a/package-lock.json b/package-lock.json index 574c2d6..ff86837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "dependencies": { "next": "16.2.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sitekit": "github:bitbaum/sitekit#v0.1.1" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^22", "@types/react": "^19", @@ -21,6 +21,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", + "tsx": "^4.23.12", "typescript": "^5" } }, @@ -310,6 +311,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -3229,6 +3672,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3800,6 +4285,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6085,6 +6585,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sitekit": { + "version": "0.1.1", + "resolved": "git+ssh://git@github.com/bitbaum/sitekit.git#cabb138d1d18d4dde24441ac519ebb7e74bab27e", + "license": "MIT", + "dependencies": { + "zod": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6428,6 +6942,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6801,7 +7334,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index cd0ec80..b8d950b 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,14 @@ "start": "next start", "lint": "eslint .", "type-check": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "tsx --test test/*.test.ts", "verify": "npm run type-check && npm run lint && npm run test" }, "dependencies": { "next": "16.2.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sitekit": "github:bitbaum/sitekit#v0.1.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -24,6 +25,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", + "tsx": "^4.23.12", "typescript": "^5" } } diff --git a/test/site-content.test.ts b/test/site-content.test.ts new file mode 100644 index 0000000..17b8163 --- /dev/null +++ b/test/site-content.test.ts @@ -0,0 +1,38 @@ +/** + * The one test this site needs: its entire content validates against the + * shared schema it is rendered with. + * + * This is the extraction's contract running in reverse — substrata is + * sitekit's reference consumer, so a schema change that would break this site + * fails HERE, in this repo's CI, before it ships to any generated site. It + * also replaces a `jest --passWithNoTests` that had never actually run: jest + * was never a dependency of this repo, so `verify`'s test gate was a no-op on + * every fresh install. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateSite } from 'sitekit'; +import { siteChrome, sitePages, siteNavItems } from '../config/site-content'; + +test('the whole site validates against the sitekit schema', () => { + const result = validateSite({ chrome: siteChrome(), pages: sitePages() }); + assert.equal( + result.success, + true, + result.success ? undefined : `schema violations:\n${result.errors.join('\n')}`, + ); +}); + +test('every nav item resolves to a page that exists', () => { + const pages = sitePages(); + const paths = new Set(pages.map(p => p.path)); + for (const item of siteNavItems(pages)) { + assert.ok(paths.has(item.path), `nav points at missing page '${item.path}'`); + } +}); + +test('the chrome carries the canonical host for the footer', () => { + assert.equal(siteChrome().host, 'substrata.orangecat.ch'); +});