diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 50a8016..7b7e457 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -261,6 +261,17 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/export.spec.ts + - name: Reset login rate limit before fonts pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + # Pond fonts (#66): the GDPR no-off-origin assertion + apply/persist. + - name: Run fonts pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/fonts.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/.gitignore b/.gitignore index dfd5a1c..cd36888 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ coverage/ test-results/ # Local upload storage for native (non-Docker) dev runs (UPLOADS_DIR default). apps/api/data/ + +# Font catalog WOFF2 + generated stylesheet — fetched at build time +# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed. +apps/web/public/fonts/ diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 1110038..06207ff 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -146,10 +146,16 @@ export class PondsService { async update(_user: User, id: string, input: UpdatePondInput): Promise { const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); if (!pond) throw new NotFoundException(); + // Stored settings hold only deviations from the defaults; merge in whichever + // of `sidebarSort` / `fonts` this request changes (issue #26 / #66). const settings = - input.sidebarSort === undefined + input.sidebarSort === undefined && input.fonts === undefined ? undefined - : { ...(pond.settings as object), sidebarSort: input.sidebarSort }; + : { + ...(pond.settings as object), + ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), + ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), + }; const updated = await this.prisma.pond.update({ where: { id }, data: { name: input.name, description: input.description, settings }, diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 1ac36cf..6e871d3 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -8,8 +8,13 @@ RUN npm install -g pnpm@11 COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY packages/shared ./packages/shared COPY apps/web ./apps/web +COPY deploy/fonts ./deploy/fonts +# Build shared, then download the catalog fonts into the web app (ADR 0016: +# self-hosted, baked into the image — never fetched from a visitor's browser), +# then build. The font step fails the image build if a family lacks license info. RUN pnpm install --frozen-lockfile --filter @dorfteich/web... \ && pnpm --filter @dorfteich/shared build \ + && node deploy/fonts/build-fonts.mjs \ && VITE_APP_VERSION=${APP_VERSION} pnpm --filter @dorfteich/web build # nginx-unprivileged runs as uid 101 and listens on 8080 — no root needed. diff --git a/apps/web/e2e/fonts.spec.ts b/apps/web/e2e/fonts.spec.ts new file mode 100644 index 0000000..3de8436 --- /dev/null +++ b/apps/web/e2e/fonts.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Pond fonts pack (issue #66, ADR 0016): the GDPR "zero external requests" + * guarantee, and that a pond's font choice applies to its pages and persists. + * Selectors are language-neutral (CSS classes, not button text). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const ORIGIN = new URL(BASE_URL).host; + +const DEFAULT_FONTS = { + heading: { family: 'Roboto', weight: 400 }, + body: { family: 'Roboto', weight: 200 }, + mono: { family: 'Fira Code', weight: 400 }, +}; + +async function personalPond( + context: Awaited>, +): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +async function createPage( + context: Awaited>, + pondId: string, + title: string, +): Promise { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } }); + return (await created.json()).slug; +} + +test('renders a pond without any request leaving the origin (GDPR)', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const slug = await createPage(context, pond.id, `Fonts Net ${Date.now()}`); + const page = await context.newPage(); + + const offOrigin: string[] = []; + page.on('request', (request) => { + const url = request.url(); + if (url.startsWith('data:') || url.startsWith('blob:')) return; + if (new URL(url).host !== ORIGIN) offOrigin.push(url); + }); + + await page.goto(`/p/${pond.slug}/${slug}`); + await page.locator('.ProseMirror').waitFor(); + // Give any late asset/font/websocket request a chance to fire. + await page.waitForTimeout(750); + + expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]); + await context.close(); +}); + +test('a pond font choice applies to its pages and persists', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const slug = await createPage(context, pond.id, `Fonts Apply ${Date.now()}`); + const page = await context.newPage(); + try { + await page.goto(`/p/${pond.slug}/settings`); + // Set the body font to a distinctive serif, then save. + await page.locator('.appearance__slot--body select').first().selectOption('Merriweather'); + await page.locator('.appearance__actions button').click(); + + await page.goto(`/p/${pond.slug}/${slug}`); + const font = await page + .locator('.ProseMirror') + .evaluate((el) => getComputedStyle(el).fontFamily); + expect(font).toContain('Merriweather'); + + // Persisted across a reload. + await page.reload(); + const afterReload = await page + .locator('.ProseMirror') + .evaluate((el) => getComputedStyle(el).fontFamily); + expect(afterReload).toContain('Merriweather'); + } finally { + // Restore defaults so reruns / other tests see a clean pond. + await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { fonts: DEFAULT_FONTS } }); + await context.close(); + } +}); + +test('a pond with no font settings renders the vision defaults', async ({ browser }) => { + // fixture-editor's personal pond is never reconfigured by these tests. + const context = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const pond = await personalPond(context); + const slug = await createPage(context, pond.id, `Fonts Default ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pond.slug}/${slug}`); + const font = await page.locator('.ProseMirror').evaluate((el) => getComputedStyle(el).fontFamily); + // Default body family is Roboto (ADR 0016). + expect(font).toContain('Roboto'); + + await context.close(); +}); diff --git a/apps/web/index.html b/apps/web/index.html index 19d9642..64aa490 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,10 @@ Dorfteich + +
diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 65b8260..0ea8184 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -23,6 +23,13 @@ server { location / { add_header Cache-Control "no-cache"; + # Strict CSP (security.md, ADR 0016): everything self-hosted, zero + # third-party origins — fonts, scripts, styles, and XHR/WebSocket all + # from this origin only (the GDPR "zero external requests" posture). + # `style-src 'unsafe-inline'` covers the app's inline style attributes + # (CSS custom properties, layout); scripts are all external files. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; + add_header X-Content-Type-Options "nosniff" always; try_files $uri /index.html; } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ce204ba..278eee9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'; import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards'; import { AppLayout } from './layout/AppLayout'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; +import { FontCatalogPage } from './pages/FontCatalogPage'; import { HomePage } from './pages/HomePage'; import { NotFoundPage } from './pages/NotFoundPage'; import { PageEditorPage } from './pages/PageEditorPage'; @@ -36,6 +37,7 @@ export function App(): React.JSX.Element { }> } /> + } /> } /> {/* Static segment "trash" wins react-router's ranking over the dynamic :pageSlug sibling below — a page slugged "trash" diff --git a/apps/web/src/fonts/AppearanceManager.tsx b/apps/web/src/fonts/AppearanceManager.tsx new file mode 100644 index 0000000..06fe3c4 --- /dev/null +++ b/apps/web/src/fonts/AppearanceManager.tsx @@ -0,0 +1,123 @@ +import { FONT_CATALOG, FontCategory, PondFonts, fontEntry, fontStack } from '@dorfteich/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { FormError } from '../components/forms'; +import { apiPatch } from '../lib/api'; + +const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono']; +const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace']; + +/** + * Pond settings 'Appearance' section (issue #66, ADR 0016): pick the heading / + * body / mono font (family + weight) from the self-hosted catalog, with a live + * preview in the chosen font, and save. Pond-Admin-gated in the api. + */ +export function AppearanceManager({ + pondId, + pondSlug, + fonts, +}: { + pondId: string; + pondSlug: string; + fonts: PondFonts; +}): React.JSX.Element { + const { t } = useTranslation('font'); + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(fonts); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [error, setError] = useState(null); + + function chooseFamily(slot: keyof PondFonts, family: string): void { + const weights = fontEntry(family)?.weights ?? []; + // Keep the current weight if the new family offers it, else its first. + const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400); + setDraft((prev) => ({ ...prev, [slot]: { family, weight } })); + setStatus('idle'); + } + + function chooseWeight(slot: keyof PondFonts, weight: number): void { + setDraft((prev) => ({ ...prev, [slot]: { ...prev[slot], weight } })); + setStatus('idle'); + } + + async function save(): Promise { + setStatus('saving'); + setError(null); + try { + await apiPatch(`/ponds/${pondId}`, { fonts: draft }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setStatus('saved'); + } catch (err) { + setError(err); + setStatus('idle'); + } + } + + return ( +
+

{t('intro')}

+ + {SLOTS.map((slot) => { + const value = draft[slot]; + const weights = fontEntry(value.family)?.weights ?? [value.weight]; + return ( +
+ {t(`slots.${slot}`)} + + +

+ {t('preview')} +

+
+ ); + })} +
+ + + {t('catalogLink')} + +
+
+ ); +} diff --git a/apps/web/src/fonts/PondFontScope.tsx b/apps/web/src/fonts/PondFontScope.tsx new file mode 100644 index 0000000..8b3d6dc --- /dev/null +++ b/apps/web/src/fonts/PondFontScope.tsx @@ -0,0 +1,40 @@ +import { PondSettings, fontStack } from '@dorfteich/shared'; +import type { CSSProperties, ReactNode } from 'react'; + +/** The CSS custom properties a pond's font choice sets on its content root + * (ADR 0016). The content CSS reads these; a family that fails to load falls + * back to the category's system stack (`fontStack`). */ +export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties { + // Overrides the same custom properties the app-wide CSS already reads + // (tokens.css), so headings, body, and code inside the scope re-resolve to + // the pond's fonts without any per-element rules. + return { + '--font-heading': fontStack(fonts.heading.family), + '--font-weight-heading': String(fonts.heading.weight), + '--font-body': fontStack(fonts.body.family), + '--font-weight-body': String(fonts.body.weight), + '--font-mono': fontStack(fonts.mono.family), + '--font-weight-mono': String(fonts.mono.weight), + } as CSSProperties; +} + +/** + * Applies a pond's fonts to its content (ADR 0016): a wrapper that sets the + * `--font-*` custom properties from `pond.settings.fonts`, so the editor and + * read view render in the pond's chosen heading/body/mono families immediately. + * A pond with no saved settings arrives with the defaulted values (Roboto 400 / + * Roboto 200 / Fira Code), so the defaults always render. + */ +export function PondFontScope({ + fonts, + children, +}: { + fonts: PondSettings['fonts']; + children: ReactNode; +}): React.JSX.Element { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 97a4a78..7c28e17 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -5,6 +5,7 @@ import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deExport from '@dorfteich/shared/i18n/de/export.json'; import deFiles from '@dorfteich/shared/i18n/de/files.json'; +import deFont from '@dorfteich/shared/i18n/de/font.json'; import deImport from '@dorfteich/shared/i18n/de/import.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; @@ -21,6 +22,7 @@ import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enExport from '@dorfteich/shared/i18n/en/export.json'; import enFiles from '@dorfteich/shared/i18n/en/files.json'; +import enFont from '@dorfteich/shared/i18n/en/font.json'; import enImport from '@dorfteich/shared/i18n/en/import.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; @@ -54,6 +56,7 @@ void i18n editor: enEditor, export: enExport, files: enFiles, + font: enFont, import: enImport, labels: enLabels, links: enLinks, @@ -72,6 +75,7 @@ void i18n editor: deEditor, export: deExport, files: deFiles, + font: deFont, import: deImport, labels: deLabels, links: deLinks, diff --git a/apps/web/src/pages/FontCatalogPage.tsx b/apps/web/src/pages/FontCatalogPage.tsx new file mode 100644 index 0000000..68a3fcf --- /dev/null +++ b/apps/web/src/pages/FontCatalogPage.tsx @@ -0,0 +1,41 @@ +import { FONT_CATALOG, fontStack } from '@dorfteich/shared'; +import { useTranslation } from 'react-i18next'; + +/** + * Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted + * family with its license, rendered in the font itself. The self-hosting is the + * GDPR guarantee — this page is the human-readable attribution surface. + */ +export function FontCatalogPage(): React.JSX.Element { + const { t } = useTranslation('font'); + return ( +
+

{t('catalog.title')}

+

{t('catalog.intro')}

+ + + + + + + + + + + {FONT_CATALOG.map((font) => ( + + + + + + + ))} + +
{t('catalog.family')}{t('catalog.category')}{t('catalog.weights')}{t('catalog.license')}
{font.family}{t(`category.${font.category}`)}{font.weights.join(', ')} + + {font.license} + +
+
+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 8a96aa5..a677f8f 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_FONTS } from '@dorfteich/shared'; import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; @@ -17,6 +18,7 @@ import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; import { DocumentExportMenu } from '../export/DocumentExportMenu'; +import { PondFontScope } from '../fonts/PondFontScope'; import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; @@ -27,6 +29,13 @@ import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { recallPage, rememberPage } from '../offline/page-cache'; +// A pond loaded offline (no settings) still renders the vision defaults. +const DEFAULT_POND_FONTS = { + heading: DEFAULT_FONTS.heading, + body: DEFAULT_FONTS.body, + mono: DEFAULT_FONTS.mono, +}; + type Mode = 'view' | 'edit'; /** The minimal page identity the editor needs — available from the API online @@ -312,46 +321,50 @@ export function PageEditorPage(): React.JSX.Element { } return ( -
-
- setTitle(event.target.value)} - onBlur={() => void saveTitle()} - /> - - setShowHistory((open) => !open)} - onToggleLabels={() => setShowLabels((open) => !open)} - /> -
-
- - {showLabels && ( - setShowLabels(false)} + +
+
+ setTitle(event.target.value)} + onBlur={() => void saveTitle()} /> - )} - {showHistory && setShowHistory(false)} />} + + setShowHistory((open) => !open)} + onToggleLabels={() => setShowLabels((open) => !open)} + /> +
+
+ + {showLabels && ( + setShowLabels(false)} + /> + )} + {showHistory && ( + setShowHistory(false)} /> + )} +
+ {/* "Linked from" appears below the content in read mode (issue #48). */} + {mode === 'view' && }
- {/* "Linked from" appears below the content in read mode (issue #48). */} - {mode === 'view' && } -
+ ); } diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index dcd95f3..b677f06 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; +import { AppearanceManager } from '../fonts/AppearanceManager'; import { LabelManager } from '../labels/LabelManager'; import { PhantomPagesView } from '../links/PhantomPagesView'; import { AccessRulesManager } from '../access/AccessRulesManager'; @@ -28,6 +29,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tErrors } = useTranslation('errors'); const { t: tFiles } = useTranslation('files'); const { t: tExport } = useTranslation('export'); + const { t: tFont } = useTranslation('font'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -72,6 +74,16 @@ export function PondSettingsPage(): React.JSX.Element { )} + {canModify && ( +
+

{tFont('heading')}

+ +
+ )}

{tExport('pond.heading')}

{tExport('pond.hint')}

diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index f97a6ae..9eb83ad 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -31,6 +31,15 @@ h4 { code, pre { font-family: var(--font-mono); + font-weight: var(--font-weight-mono); +} + +/* A pond's content root (ADR 0016): sets --font-* (via PondFontScope) so its + * body text uses the pond's body font; headings and code re-resolve the same + * variables through the global rules above. */ +.pond-font-scope { + font-family: var(--font-body); + font-weight: var(--font-weight-body); } a { @@ -501,6 +510,56 @@ button { margin-bottom: var(--space-2); } +/* Pond appearance (fonts) settings — issue #66 */ +.appearance__intro { + color: var(--color-text-muted); +} + +.appearance__slot { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); + padding: var(--space-3) 0; + border-top: 1px solid var(--color-border); +} + +.appearance__slot-label { + flex-basis: 100%; + font-weight: 600; +} + +.appearance__field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.appearance__preview { + flex: 1 1 16rem; + margin: 0; + font-size: 1.1rem; +} + +.appearance__actions { + display: flex; + align-items: center; + gap: var(--space-4); + margin-top: var(--space-3); +} + +.font-catalog__table { + border-collapse: collapse; + width: 100%; +} + +.font-catalog__table th, +.font-catalog__table td { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); +} + .editor-shell { border: 1px solid var(--color-border); border-radius: var(--radius); diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 337c02b..96b6fcc 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -10,6 +10,7 @@ --font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, monospace; --font-weight-heading: 400; --font-weight-body: 300; + --font-weight-mono: 400; /* Color palette: calm neutrals with one pond-green accent. */ --color-text: #1f2933; diff --git a/deploy/fonts/build-fonts.mjs b/deploy/fonts/build-fonts.mjs new file mode 100644 index 0000000..4cc0580 --- /dev/null +++ b/deploy/fonts/build-fonts.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Font catalog build step (ADR 0016): download each catalog family's WOFF2 +// weights from google-webfonts-helper (upstream gstatic) and bake them into the +// web app under public/fonts/, plus generate the @font-face stylesheet. Run at +// image build time (the web Dockerfile) — no runtime download, no third-party +// request from a visitor's browser (CSP `font-src 'self'`). +// +// node deploy/fonts/build-fonts.mjs +// +// Requires the shared package to be built (it owns the catalog). The build +// FAILS if any catalog entry lacks license info — attribution is mandatory. + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +// Import from the built shared package directly — this script runs outside any +// workspace package, so `@dorfteich/shared` isn't resolvable by name here. +const { FONT_CATALOG, fontSlug } = await import( + join(ROOT, 'packages', 'shared', 'dist', 'index.mjs') +); +const OUT_DIR = join(ROOT, 'apps', 'web', 'public', 'fonts'); +const GWFH = 'https://gwfh.mranftl.com/api/fonts'; +const SUBSETS = 'latin,latin-ext'; + +/** Reject a catalog that would ship a font without attribution (AC). */ +function validateCatalog() { + const bad = FONT_CATALOG.filter( + (e) => !e.license || !e.licenseUrl || !e.family || e.weights.length === 0, + ); + if (bad.length > 0) { + throw new Error( + `Font catalog entries missing license/weights: ${bad.map((e) => e.family).join(', ')}`, + ); + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** GET with a few retries — the image build depends on upstream (gwfh / + * gstatic), so a transient hiccup must not fail the whole build. */ +async function get(url) { + let lastError; + for (let attempt = 1; attempt <= 4; attempt += 1) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} → ${res.status}`); + return res; + } catch (error) { + lastError = error; + if (attempt < 4) await sleep(attempt * 1000); + } + } + throw lastError; +} + +async function fetchJson(url) { + return (await get(url)).json(); +} + +async function fetchBytes(url) { + return Buffer.from(await (await get(url)).arrayBuffer()); +} + +async function build() { + validateCatalog(); + await mkdir(OUT_DIR, { recursive: true }); + const faces = []; + let total = 0; + + for (const entry of FONT_CATALOG) { + const slug = fontSlug(entry.family); + const meta = await fetchJson(`${GWFH}/${entry.id}?subsets=${SUBSETS}`); + const byWeight = new Map( + meta.variants.filter((v) => v.fontStyle === 'normal').map((v) => [Number(v.fontWeight), v]), + ); + await mkdir(join(OUT_DIR, slug), { recursive: true }); + for (const weight of entry.weights) { + const variant = byWeight.get(weight); + if (!variant) throw new Error(`${entry.family}: upstream has no weight ${weight}`); + const bytes = await fetchBytes(variant.woff2); + const file = `${slug}/${slug}-${weight}.woff2`; + await writeFile(join(OUT_DIR, file), bytes); + faces.push( + `@font-face {\n` + + ` font-family: '${entry.family}';\n` + + ` font-style: normal;\n` + + ` font-weight: ${weight};\n` + + ` font-display: swap;\n` + + ` src: url('/fonts/${file}') format('woff2');\n` + + `}`, + ); + total += bytes.length; + console.log( + ` ${entry.family} ${weight} → ${file} (${(bytes.length / 1024).toFixed(1)} KiB)`, + ); + } + } + + const header = + '/* Generated by deploy/fonts/build-fonts.mjs (ADR 0016) — do not edit.\n' + + ' Self-hosted catalog fonts; served only from this origin. */\n\n'; + await writeFile(join(OUT_DIR, 'catalog.css'), header + faces.join('\n\n') + '\n'); + console.log( + `\nWrote ${faces.length} @font-face rules for ${FONT_CATALOG.length} families ` + + `(${(total / 1024 / 1024).toFixed(2)} MiB) to ${OUT_DIR}`, + ); +} + +build().catch((error) => { + console.error(`font build failed: ${error.message}`); + process.exit(1); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index d4a6bbf..08263a4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,8 +19,8 @@ export default tseslint.config( ...tseslint.configs.recommended, prettier, { - // Plain-Node maintenance scripts (no TypeScript, no bundler). - files: ['scripts/**/*.mjs'], + // Plain-Node maintenance/build scripts (no TypeScript, no bundler). + files: ['scripts/**/*.mjs', 'deploy/**/*.mjs'], languageOptions: { globals: { console: 'readonly', @@ -28,6 +28,7 @@ export default tseslint.config( URL: 'readonly', fetch: 'readonly', Buffer: 'readonly', + setTimeout: 'readonly', }, }, }, diff --git a/packages/shared/i18n/de/font.json b/packages/shared/i18n/de/font.json new file mode 100644 index 0000000..7051e3b --- /dev/null +++ b/packages/shared/i18n/de/font.json @@ -0,0 +1,28 @@ +{ + "heading": "Darstellung", + "intro": "Wähle die Schriften für Überschriften, Fließtext und Code dieses Teichs. Die Schriften werden ausschließlich von dieser Instanz ausgeliefert.", + "slots": { + "heading": "Überschriften", + "body": "Fließtext", + "mono": "Code" + }, + "family": "Schrift", + "weight": "Schnitt", + "preview": "Franz jagt im komplett verwahrlosten Taxi quer durch Bayern. 0123", + "save": "Schriften speichern", + "saved": "Gespeichert", + "catalogLink": "Schrift-Lizenzen", + "catalog": { + "title": "Schriftkatalog", + "intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO). Alle Schriften sind frei unter den angegebenen Lizenzen.", + "family": "Schrift", + "category": "Stil", + "weights": "Schnitte", + "license": "Lizenz" + }, + "category": { + "sans-serif": "Serifenlos", + "serif": "Serif", + "monospace": "Dicktengleich" + } +} diff --git a/packages/shared/i18n/en/font.json b/packages/shared/i18n/en/font.json new file mode 100644 index 0000000..fe4193a --- /dev/null +++ b/packages/shared/i18n/en/font.json @@ -0,0 +1,28 @@ +{ + "heading": "Appearance", + "intro": "Choose the fonts for this pond's headings, body text, and code. Fonts are served from this instance only.", + "slots": { + "heading": "Headings", + "body": "Body text", + "mono": "Code" + }, + "family": "Font", + "weight": "Weight", + "preview": "The quick brown fox jumps over the lazy dog. 0123", + "save": "Save fonts", + "saved": "Saved", + "catalogLink": "Font licenses", + "catalog": { + "title": "Font catalog", + "intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR). Fonts are free/libre under the licenses shown.", + "family": "Font", + "category": "Style", + "weights": "Weights", + "license": "License" + }, + "category": { + "sans-serif": "Sans-serif", + "serif": "Serif", + "monospace": "Monospace" + } +} diff --git a/packages/shared/src/fonts.test.ts b/packages/shared/src/fonts.test.ts new file mode 100644 index 0000000..a4ae9a3 --- /dev/null +++ b/packages/shared/src/fonts.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_FONTS, FONT_CATALOG, fontEntry, fontSlug, fontStack } from './fonts'; + +describe('font catalog (issue #66, ADR 0016)', () => { + it('is non-trivial and has unique families', () => { + expect(FONT_CATALOG.length).toBeGreaterThanOrEqual(15); + const families = FONT_CATALOG.map((f) => f.family); + expect(new Set(families).size).toBe(families.length); + }); + + it('every entry carries license info and weights (what the build step enforces)', () => { + for (const entry of FONT_CATALOG) { + expect(entry.family, entry.id).toBeTruthy(); + expect(entry.license, entry.family).toMatch(/OFL-1\.1|Apache-2\.0/); + expect(entry.licenseUrl, entry.family).toMatch(/^https:\/\//); + expect(entry.weights.length, entry.family).toBeGreaterThan(0); + expect(entry.id, entry.family).toMatch(/^[a-z0-9-]+$/); + } + }); + + it('the vision defaults exist in the catalog at their weights', () => { + for (const slot of ['heading', 'body', 'mono'] as const) { + const { family, weight } = DEFAULT_FONTS[slot]; + const entry = fontEntry(family); + expect(entry, `${slot} default family`).toBeDefined(); + expect(entry!.weights, `${family} @ ${weight}`).toContain(weight); + } + }); + + it('builds a stack with the family ahead of a category fallback', () => { + expect(fontStack('Roboto')).toBe("'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif"); + expect(fontStack('Merriweather')).toContain("'Merriweather'"); + expect(fontStack('Fira Code')).toContain('monospace'); + // An unknown family falls back to the sans system stack, no bogus family. + expect(fontStack('Nonexistent Font')).not.toContain('Nonexistent'); + }); + + it('slugs families to the on-disk /fonts layout', () => { + expect(fontSlug('Source Serif 4')).toBe('source-serif-4'); + expect(fontSlug('IBM Plex Mono')).toBe('ibm-plex-mono'); + }); +}); diff --git a/packages/shared/src/fonts.ts b/packages/shared/src/fonts.ts new file mode 100644 index 0000000..e36e654 --- /dev/null +++ b/packages/shared/src/fonts.ts @@ -0,0 +1,188 @@ +/** + * The curated self-hosted font catalog (ADR 0016). One maintained list drives + * everything: the build step downloads these families' WOFF2 subsets into the + * web image, the pond-settings Appearance UI offers them, and the attribution + * page lists their licenses. Adding a font is a change here + an image rebuild + * — there is no runtime font management (deliberately small surface). + * + * Fonts are served only from the instance itself (`font-src 'self'`); a + * visitor's browser makes zero third-party requests (the GDPR guarantee, + * security.md). + */ +export type FontCategory = 'sans-serif' | 'serif' | 'monospace'; +export type FontLicense = 'OFL-1.1' | 'Apache-2.0'; + +export interface FontCatalogEntry { + /** google-webfonts-helper id — the download key for the build step. */ + id: string; + /** CSS `font-family` name. */ + family: string; + category: FontCategory; + /** Weights downloaded and offered (a subset of what upstream provides). */ + weights: number[]; + license: FontLicense; + licenseUrl: string; +} + +/** Every catalog entry MUST carry license info — the build step fails otherwise + * (ADR 0016). Roboto ships under Apache-2.0; the rest under the SIL OFL 1.1. */ +export const FONT_CATALOG: readonly FontCatalogEntry[] = [ + { + id: 'roboto', + family: 'Roboto', + category: 'sans-serif', + weights: [200, 300, 400, 500, 700], + license: 'Apache-2.0', + licenseUrl: 'https://www.apache.org/licenses/LICENSE-2.0', + }, + { + id: 'open-sans', + family: 'Open Sans', + category: 'sans-serif', + weights: [300, 400, 600, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'lato', + family: 'Lato', + category: 'sans-serif', + weights: [300, 400, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'montserrat', + family: 'Montserrat', + category: 'sans-serif', + weights: [300, 400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'inter', + family: 'Inter', + category: 'sans-serif', + weights: [300, 400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'nunito', + family: 'Nunito', + category: 'sans-serif', + weights: [300, 400, 600, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'pt-sans', + family: 'PT Sans', + category: 'sans-serif', + weights: [400, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'merriweather', + family: 'Merriweather', + category: 'serif', + weights: [300, 400, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'lora', + family: 'Lora', + category: 'serif', + weights: [400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'source-serif-4', + family: 'Source Serif 4', + category: 'serif', + weights: [300, 400, 600, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'playfair-display', + family: 'Playfair Display', + category: 'serif', + weights: [400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'fira-code', + family: 'Fira Code', + category: 'monospace', + weights: [300, 400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'ibm-plex-mono', + family: 'IBM Plex Mono', + category: 'monospace', + weights: [300, 400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'jetbrains-mono', + family: 'JetBrains Mono', + category: 'monospace', + weights: [400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, + { + id: 'source-code-pro', + family: 'Source Code Pro', + category: 'monospace', + weights: [400, 500, 700], + license: 'OFL-1.1', + licenseUrl: 'https://openfontlicense.org', + }, +]; + +/** Per-vision defaults (ADR 0016) — a pond with no saved fonts renders these. + * Kept in step with `pondSettingsSchema`'s `fonts` defaults. */ +export const DEFAULT_FONTS = { + heading: { family: 'Roboto', weight: 400 }, + body: { family: 'Roboto', weight: 200 }, + mono: { family: 'Fira Code', weight: 400 }, +} as const; + +/** System fallback per category — used while a WOFF2 loads and if it is absent. */ +export const FONT_FALLBACKS: Readonly> = { + 'sans-serif': "system-ui, -apple-system, 'Segoe UI', sans-serif", + serif: "Georgia, 'Times New Roman', serif", + monospace: "ui-monospace, 'SFMono-Regular', Menlo, monospace", +}; + +/** URL/file-safe slug for a family (matches the on-disk `/fonts//` layout). */ +export function fontSlug(family: string): string { + return family + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + +export function fontEntry(family: string): FontCatalogEntry | undefined { + return FONT_CATALOG.find((entry) => entry.family === family); +} + +/** + * The `font-family` stack for a chosen family: the family itself (when it is in + * the catalog) ahead of the category's system fallback, so text stays readable + * before the WOFF2 loads or if the catalog font is unknown. + */ +export function fontStack(family: string): string { + const entry = fontEntry(family); + const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif']; + return entry ? `'${family}', ${fallback}` : fallback; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9465e00..20d8040 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export * from './editor-schema'; export * from './env'; export * from './conversion'; export * from './files'; +export * from './fonts'; export * from './health'; export * from './i18n-tools'; export * from './labels'; diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 8c5cddb..fb5cac8 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -18,6 +18,14 @@ const fontSlotSchema = z.object({ weight: z.number().int().min(100).max(900), }); +/** The three font slots, each defaulted to the vision fonts (ADR 0016). */ +export const pondFontsSchema = z.object({ + heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }), + body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }), + mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }), +}); +export type PondFonts = z.infer; + /** * Pond `settings` jsonb. Parsing `{}` yields the documented defaults * (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code, @@ -26,13 +34,7 @@ const fontSlotSchema = z.object({ */ export const pondSettingsSchema = z.object({ sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'), - fonts: z - .object({ - heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }), - body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }), - mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }), - }) - .default({}), + fonts: pondFontsSchema.default({}), }); export type PondSettings = z.infer; @@ -53,6 +55,7 @@ export const updatePondInputSchema = z name: pondNameSchema, description: z.string().trim().max(500, 'validation.tooLong'), sidebarSort: z.enum(SIDEBAR_SORT_MODES), + fonts: pondFontsSchema, }) .partial(); export type UpdatePondInput = z.infer;