diff --git a/.zed/settings.json b/.zed/settings.json index e18b72e..bcf98bb 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -15,6 +15,7 @@ "css": { "lint": { "propertyIgnoredDueToDisplay": "ignore", + "unknownAtRules": "ignore", }, }, }, diff --git a/astro.config.mjs b/astro.config.mjs index 2e0871b..a9b90ce 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,13 +2,24 @@ import { defineConfig, fontProviders } from 'astro/config' import keystatic from '@keystatic/astro' -import markdoc from '@astrojs/markdoc' import node from '@astrojs/node' import react from '@astrojs/react' +import { contentReload } from './src/integrations/content-reload' + +const devOnly = (integration) => ({ + name: `${integration.name}:dev-only`, + hooks: { + 'astro:config:setup': async (params) => { + if (params.command !== 'dev') return + await integration.hooks['astro:config:setup']?.(params) + }, + }, +}) + export default defineConfig({ site: 'https://dave-dmg.de', - integrations: [react(), markdoc(), keystatic()], + integrations: [react(), devOnly(keystatic()), contentReload()], adapter: node({ mode: 'standalone', @@ -18,11 +29,13 @@ export default defineConfig({ provider: fontProviders.google(), name: 'Geist', cssVariable: '--font-geist', + weights: ['100 900'], }, { provider: fontProviders.google(), name: 'Geist Mono', cssVariable: '--font-geist-mono', + weights: ['100 900'], }, { provider: fontProviders.google(), diff --git a/eslint.config.mjs b/eslint.config.mjs index 8acfa76..ee7304a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,7 @@ import prettier from 'eslint-config-prettier/flat' import astro from 'eslint-plugin-astro' import reactHooks from 'eslint-plugin-react-hooks' import unusedImports from 'eslint-plugin-unused-imports' +import globals from 'globals' import tseslint from 'typescript-eslint' export default [ @@ -11,7 +12,10 @@ export default [ js.configs.recommended, ...tseslint.configs.recommended, ...astro.configs.recommended, - + { + name: 'dave-dmg/node', + languageOptions: { globals: globals.node }, + }, { name: 'dave-dmg/unused', files: ['**/*.{js,mjs,cjs,ts,tsx,astro}'], diff --git a/keystatic.config.ts b/keystatic.config.ts index ad94f79..34c61d6 100644 --- a/keystatic.config.ts +++ b/keystatic.config.ts @@ -1,8 +1,21 @@ import { config } from '@keystatic/core' +import { contentCollections } from '@/keystatic/collections/general' +import { siteCollections } from '@/keystatic/collections/site' +import { ARTICLES, PAGES, RELEASES, TAGS } from '@/lib/constants/collections' + export default config({ storage: { kind: 'local', }, - collections: {}, + ui: { + navigation: { + Content: [ARTICLES, PAGES, TAGS], + Site: [RELEASES], + }, + }, + collections: { + ...contentCollections, + ...siteCollections, + }, }) diff --git a/package.json b/package.json index ab195b5..a40681b 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,24 @@ "preview": "astro preview" }, "dependencies": { - "@astrojs/markdoc": "^2.0.1", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", + "@keystar/ui": "^0.10.0", "@keystatic/astro": "^6.0.0", "@keystatic/core": "^0.6.7", + "@markdoc/markdoc": "^0.4.0", "astro": "^7.0.4", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "sharp": "^0.35.4", + "shiki": "^4.4.3" }, "devDependencies": { "@astrojs/check": "^0.9.10", "@csstools/postcss-global-data": "^4.0.0", "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@types/node": "^22.20.4", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "eslint": "^10.11.0", @@ -33,6 +37,7 @@ "eslint-plugin-astro": "^3.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.12.0", "postcss": "^8.5.28", "postcss-custom-media": "^12.0.1", "postcss-mixins": "^12.1.2", @@ -44,7 +49,8 @@ "stylelint-config-html": "^2.0.0", "stylelint-config-standard": "^40.0.0", "typescript": "~6.0.3", - "typescript-eslint": "^8.70.0" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0" }, "packageManager": "pnpm@10.15.0", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7135ba..7b5f4f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,30 +11,39 @@ importers: .: dependencies: - '@astrojs/markdoc': - specifier: ^2.0.1 - version: 2.0.9(@types/react@19.3.0)(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react@19.3.0) '@astrojs/node': specifier: ^11.0.0 - version: 11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) + version: 11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) '@astrojs/react': specifier: ^6.0.0 - version: 6.0.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + version: 6.0.6(@types/node@22.20.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + '@keystar/ui': + specifier: ^0.10.0 + version: 0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0) '@keystatic/astro': specifier: ^6.0.0 - version: 6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@keystatic/core': specifier: ^0.6.7 version: 0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0) + '@markdoc/markdoc': + specifier: ^0.4.0 + version: 0.4.0(@types/react@19.3.0)(react@19.3.0) astro: specifier: ^7.0.4 - version: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + version: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) react: specifier: ^19.2.8 version: 19.3.0 react-dom: specifier: ^19.2.8 version: 19.3.0(react@19.3.0) + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@22.20.4) + shiki: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -48,6 +57,9 @@ importers: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.7.1 version: 4.7.1(prettier@3.9.8) + '@types/node': + specifier: ^22.20.4 + version: 22.20.4 '@types/react': specifier: ^19.2.18 version: 19.3.0 @@ -69,6 +81,9 @@ importers: eslint-plugin-unused-imports: specifier: ^4.4.1 version: 4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0)(typescript@6.0.3))(eslint@10.11.0)(typescript@6.0.3))(eslint@10.11.0) + globals: + specifier: ^17.12.0 + version: 17.12.0 postcss: specifier: ^8.5.28 version: 8.5.28 @@ -105,6 +120,9 @@ importers: typescript-eslint: specifier: ^8.70.0 version: 8.70.0(eslint@10.11.0)(typescript@6.0.3) + vite: + specifier: ^8.3.0 + version: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) packages: @@ -208,12 +226,6 @@ packages: prettier-plugin-astro: optional: true - '@astrojs/markdoc@2.0.9': - resolution: {integrity: sha512-1w9Zg9czdfZXjBx9xv/eUy+65cOEr5AG6nsD36ZF3Eytir5Uip/0WpALLdOg6uN35wNbws+ZaibH5aAdC/6Jyg==} - engines: {node: '>=22.12.0'} - peerDependencies: - astro: ^7.0.0 - '@astrojs/markdown-satteri@0.4.1': resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==} @@ -1001,18 +1013,6 @@ packages: react: optional: true - '@markdoc/markdoc@0.5.10': - resolution: {integrity: sha512-5HwkG2cLG/ivffeJlxGmraJ9erYfsaYz9rjG1KCf46PLy+yhrBQWw26h69GewNUHbyKNW6FrXuDYgbl2kvIYdA==} - engines: {node: '>=14.7.0'} - peerDependencies: - '@types/react': '*' - react: '*' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1259,6 +1259,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -1694,32 +1697,16 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-serializer@3.1.1: - resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} - engines: {node: '>=20.19.0'} - domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domelementtype@3.0.0: - resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} - engines: {node: '>=20.19.0'} - domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - domhandler@6.0.1: - resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} - engines: {node: '>=20.19.0'} - domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - domutils@4.0.2: - resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} - engines: {node: '>=20.19.0'} - dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -1750,10 +1737,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2073,10 +2056,6 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - htmlparser2@12.0.0: - resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} - engines: {node: '>=20.19.0'} - htmlparser2@9.1.0: resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} @@ -3154,6 +3133,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} @@ -3658,19 +3640,6 @@ snapshots: - '@emnapi/runtime' - typescript - '@astrojs/markdoc@2.0.9(@types/react@19.3.0)(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react@19.3.0)': - dependencies: - '@astrojs/internal-helpers': 0.11.0 - '@astrojs/prism': 4.0.2 - '@markdoc/markdoc': 0.5.10(@types/react@19.3.0)(react@19.3.0) - astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) - esbuild: 0.28.2 - github-slugger: 2.0.0 - htmlparser2: 12.0.0 - transitivePeerDependencies: - - '@types/react' - - react - '@astrojs/markdown-satteri@0.4.1': dependencies: '@astrojs/internal-helpers': 0.11.0 @@ -3678,10 +3647,10 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/node@11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))': + '@astrojs/node@11.1.6(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))': dependencies: '@astrojs/internal-helpers': 0.11.0 - astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) send: 1.2.1 server-destroy: 1.0.1 transitivePeerDependencies: @@ -3691,17 +3660,17 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@6.0.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)': + '@astrojs/react@6.0.6(@types/node@22.20.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)': dependencies: '@astrojs/internal-helpers': 0.11.0 '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@vitejs/plugin-react': 5.2.0(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) + '@vitejs/plugin-react': 5.2.0(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) devalue: 5.9.4 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) ultrahtml: 1.7.0 - vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -4190,8 +4159,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: @@ -4350,12 +4318,12 @@ snapshots: react-dom: 19.3.0(react@19.3.0) react-stately: 3.48.0(react@19.3.0) - '@keystatic/astro@6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@keystatic/astro@6.0.0(@keystatic/core@0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 '@keystatic/core': 0.6.9(@keystar/ui@0.10.0(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0))(react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react-stately@3.48.0(react@19.3.0))(react@19.3.0) '@types/react': 19.3.0 - astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + astro: 7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -4442,13 +4410,6 @@ snapshots: '@types/react': 19.3.0 react: 19.3.0 - '@markdoc/markdoc@0.5.10(@types/react@19.3.0)(react@19.3.0)': - optionalDependencies: - '@types/linkify-it': 3.0.5 - '@types/markdown-it': 12.2.3 - '@types/react': 19.3.0 - react: 19.3.0 - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -4673,6 +4634,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -4803,7 +4768,7 @@ snapshots: '@urql/core': 5.2.0(graphql@16.14.2) wonka: 6.3.6 - '@vitejs/plugin-react@5.2.0(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))': + '@vitejs/plugin-react@5.2.0(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -4811,7 +4776,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) transitivePeerDependencies: - supports-color @@ -4938,7 +4903,7 @@ snapshots: - '@emnapi/runtime' - supports-color - astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1): + astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@22.20.4)(idb-keyval@6.3.0)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1): dependencies: '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.11.0 @@ -4988,13 +4953,13 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(idb-keyval@6.3.0) verkit: 0.4.1 - vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) - vitefu: 1.1.3(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) + vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.6.5 optionalDependencies: - sharp: 0.35.4 + sharp: 0.35.4(@types/node@22.20.4) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -5212,36 +5177,18 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 - dom-serializer@3.1.1: - dependencies: - domelementtype: 3.0.0 - domhandler: 6.0.1 - entities: 8.1.0 - domelementtype@2.3.0: {} - domelementtype@3.0.0: {} - domhandler@5.0.3: dependencies: domelementtype: 2.3.0 - domhandler@6.0.1: - dependencies: - domelementtype: 3.0.0 - domutils@3.2.2: dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 - domutils@4.0.2: - dependencies: - dom-serializer: 3.1.1 - domelementtype: 3.0.0 - domhandler: 6.0.1 - dset@3.1.4: {} ee-first@1.1.1: {} @@ -5263,8 +5210,6 @@ snapshots: entities@4.5.0: {} - entities@8.1.0: {} - env-paths@2.2.1: {} error-ex@1.3.4: @@ -5613,13 +5558,6 @@ snapshots: html-void-elements@3.0.0: {} - htmlparser2@12.0.0: - dependencies: - domelementtype: 3.0.0 - domhandler: 6.0.1 - domutils: 4.0.2 - entities: 8.1.0 - htmlparser2@9.1.0: dependencies: domelementtype: 2.3.0 @@ -6630,7 +6568,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.4: + sharp@0.35.4(@types/node@22.20.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -6661,7 +6599,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - optional: true + '@types/node': 22.20.4 shebang-command@2.0.0: dependencies: @@ -6939,6 +6877,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@6.21.0: {} + undici@8.10.2: {} unicorn-magic@0.4.0: {} @@ -7033,7 +6973,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1): + vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -7041,14 +6981,15 @@ snapshots: rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 22.20.4 esbuild: 0.28.2 fsevents: 2.3.3 sugarss: 5.0.1(postcss@8.5.28) yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)): + vitefu@1.1.3(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1)): optionalDependencies: - vite: 8.3.0(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) + vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(sugarss@5.0.1(postcss@8.5.28))(yaml@2.9.1) volar-service-css@0.0.71(@volar/language-service@2.4.28): dependencies: diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..995d64d --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://dave-dmg.de/sitemap.xml diff --git a/src/assets/content/articles/prima-materia/john_martin_-_the_great_day_of_his_wrath_-_google_art_project.jpg b/src/assets/content/articles/prima-materia/john_martin_-_the_great_day_of_his_wrath_-_google_art_project.jpg new file mode 100644 index 0000000..03d0afc Binary files /dev/null and b/src/assets/content/articles/prima-materia/john_martin_-_the_great_day_of_his_wrath_-_google_art_project.jpg differ diff --git a/src/assets/content/articles/prima-materia/summary/cover/image.jpg b/src/assets/content/articles/prima-materia/summary/cover/image.jpg new file mode 100644 index 0000000..03d0afc Binary files /dev/null and b/src/assets/content/articles/prima-materia/summary/cover/image.jpg differ diff --git a/src/components/dev/DevBanner.astro b/src/components/dev/DevBanner.astro new file mode 100644 index 0000000..94ccf13 --- /dev/null +++ b/src/components/dev/DevBanner.astro @@ -0,0 +1,54 @@ +--- +import type { Status } from '@/lib/constants/status' +import { forEntry, formatDiagnostics } from '@/lib/diagnostics/report' +import type { Diagnostic } from '@/lib/diagnostics/types' +import { getSite } from '@/lib/site' + +type Props = { + collection: string + id: string + status?: Status + diagnostics?: Diagnostic[] +} + +const { collection, id, status, diagnostics = [] } = Astro.props + +const NOTICE: Partial> = { + draft: 'Draft – not built in production', + unlisted: 'Unlisted – has page, but appears in no list', +} + +const site = import.meta.env.DEV ? await getSite() : null +const here = site ? [...forEntry(site.diagnostics, collection, id), ...diagnostics] : [] +const elsewhere = site ? site.diagnostics.filter((d) => !here.includes(d)) : [] +const notice = status ? NOTICE[status] : undefined +const label = [status && status !== 'published' ? status : null, here.length || null] + .filter(Boolean) + .join(' · ') +--- + +{site && (here.length || elsewhere.length || notice) && ( +
+ + Dev{label && {label}} + +
+ {notice &&

{notice}

} + {here.length > 0 && ( +
    + {here.map((d) => ( +
  • + {d.message} +
  • + ))} +
+ )} + {elsewhere.length > 0 && ( +
+ {elsewhere.length} elsewhere +
{formatDiagnostics(elsewhere)}
+
+ )} +
+
+)} diff --git a/src/components/home/ActivityStrip.astro b/src/components/home/ActivityStrip.astro new file mode 100644 index 0000000..bea4f7d --- /dev/null +++ b/src/components/home/ActivityStrip.astro @@ -0,0 +1,31 @@ +--- +import type { Activity } from '@/lib/site/types' + +type Props = { + activity: Activity[] +} + +const { activity } = Astro.props +const total = activity.length +const touched = activity.filter((cell) => cell.touched).length +const latest = activity.at(-1)?.touched ?? false +const summary = `Updated in ${touched} of ${total} recent release${total === 1 ? '' : 's'}${latest ? ', including the latest' : ''}` +--- + +{total > 0 && ( +

+ {summary} +

+)} diff --git a/src/components/home/Changelog.astro b/src/components/home/Changelog.astro new file mode 100644 index 0000000..5b3213f --- /dev/null +++ b/src/components/home/Changelog.astro @@ -0,0 +1,47 @@ +--- +import { CHANGE_TYPES } from '@/lib/constants/changelog' +import { toMilitaryDTG } from '@/lib/content/dates' +import { trailOf } from '@/lib/site/navigation' +import type { Change, Release } from '@/lib/site/types' + +type Props = { + releases: Release[] +} + +const LIMIT = 4 + +const { releases } = Astro.props + +const labelOf = (kind: Change['kind']) => + CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind + +const workOf = (change: Change) => (change.article ? trailOf(change.article)[0].title : 'Site') +--- + +
    + {releases.map((release) => { + const more = release.changes.length - LIMIT + return ( +
  1. +
    +

    v{release.version}

    + +
    +
      + {release.changes.slice(0, LIMIT).map((change) => ( +
    • + {labelOf(change.kind)} + {workOf(change)} +

      + {change.article ? {change.text} : change.text} +

      +
    • + ))} +
    + {more > 0 &&

    +{more} more

    } +
  2. + ) + })} +
diff --git a/src/components/home/HomeHero.astro b/src/components/home/HomeHero.astro new file mode 100644 index 0000000..7b9caf7 --- /dev/null +++ b/src/components/home/HomeHero.astro @@ -0,0 +1,67 @@ +--- +import { SITE } from '@/lib/constants/site' +import { toMilitaryDTG } from '@/lib/content/dates' +import type { Release } from '@/lib/site/types' + +type Props = { + works: number + entries: number + release?: Release +} + +const { works, entries, release } = Astro.props +--- + +
+
+

Index

+

{SITE.title}

+
+
+
+ + About +
+
{SITE.tagline}
+
+
+
+ + Works +
+
{works}
+
Top-level
+
+
+
+ + Entries +
+
{entries}
+
Articles
+
+
+
+ + Latest release +
+
{release ? `v${release.version}` : '—'}
+
+ {release ? ( + + ) : ( + 'None yet' + )} +
+
+
+
+
diff --git a/src/components/home/HomeSection.astro b/src/components/home/HomeSection.astro new file mode 100644 index 0000000..aef6b60 --- /dev/null +++ b/src/components/home/HomeSection.astro @@ -0,0 +1,21 @@ +--- +import { toRoman } from '@/lib/content/numerals' + +type Props = { + index: number + title: string +} + +const { index, title } = Astro.props +const id = `home-section-${index}` +--- + +
+

+ + {title} +

+ +
diff --git a/src/components/home/WorkList.astro b/src/components/home/WorkList.astro new file mode 100644 index 0000000..02847c0 --- /dev/null +++ b/src/components/home/WorkList.astro @@ -0,0 +1,44 @@ +--- +import { toRoman } from '@/lib/content/numerals' +import { activityOf } from '@/lib/site/activity' +import type { Article, Release } from '@/lib/site/types' + +import ActivityStrip from './ActivityStrip.astro' + +type Props = { + works: Article[] + releases: Release[] +} + +const { works, releases } = Astro.props +--- + +
    + {works.map((work, i) => ( +
  1. + +
    +

    + {work.title} +

    + {work.summary.excerpt &&

    {work.summary.excerpt}

    } +
    +
    + {work.children.length > 0 ? ( + + ) : ( +

    —

    + )} + +
    +
  2. + ))} +
diff --git a/src/components/markdoc/Node.astro b/src/components/markdoc/Node.astro new file mode 100644 index 0000000..dcb6d19 --- /dev/null +++ b/src/components/markdoc/Node.astro @@ -0,0 +1,47 @@ +--- +import type { AstroComponentFactory } from 'astro/runtime/server/index.js' + +import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc' + +import ContentImage from '@/components/media/ContentImage.astro' + +import Figure from '../media/Figure.astro' +import Callout from './blocks/general/Callout.astro' +import Code from './blocks/general/Code.astro' +import Definition from './blocks/general/Definitions/Definition.astro' +import DefinitionList from './blocks/general/Definitions/DefinitionList.astro' +import Lead from './blocks/general/Lead.astro' +import Quote from './blocks/general/Quote.astro' +import Reveal from './blocks/general/Reveal.astro' +import TableWrapper from './blocks/general/TableWrapper.astro' + +type Props = { node: RenderableTreeNode | RenderableTreeNode[] } + +const COMPONENTS: Record = { + Callout, + Reveal, + Code, + Figure, + TableWrapper, + Quote, + Definition, + DefinitionList, + Lead, + Image: ContentImage, +} +const { node } = Astro.props +const isTag = Markdoc.Tag.isTag(node) +const Element = isTag ? (COMPONENTS[node.name] ?? node.name) : null +--- + +{Array.isArray(node) ? ( + node.map((child) => ) +) : isTag && Element ? ( + + {node.children.map((child) => ( + + ))} + +) : typeof node === 'string' || typeof node === 'number' ? ( + node +) : null} diff --git a/src/components/markdoc/blocks/general/Callout.astro b/src/components/markdoc/blocks/general/Callout.astro new file mode 100644 index 0000000..23b040b --- /dev/null +++ b/src/components/markdoc/blocks/general/Callout.astro @@ -0,0 +1,21 @@ +--- +import { NOTE_TYPES, type CalloutType } from '@/lib/constants/blocks/notes' + +type Props = { + type?: CalloutType + title?: string +} + +const { type = 'default', title } = Astro.props + +const kind = NOTE_TYPES.find((option) => option.value === type)?.label ?? type +--- + + diff --git a/src/components/markdoc/blocks/general/Code.astro b/src/components/markdoc/blocks/general/Code.astro new file mode 100644 index 0000000..01d3b28 --- /dev/null +++ b/src/components/markdoc/blocks/general/Code.astro @@ -0,0 +1,14 @@ +--- +type Props = { + code: string + lang?: string + html?: string +} + +const { code, lang, html } = Astro.props +--- + +
+ {lang && {lang}} + {html ? :
{code}
} +
diff --git a/src/components/markdoc/blocks/general/Definitions/Definition.astro b/src/components/markdoc/blocks/general/Definitions/Definition.astro new file mode 100644 index 0000000..9e24d54 --- /dev/null +++ b/src/components/markdoc/blocks/general/Definitions/Definition.astro @@ -0,0 +1,23 @@ +--- +import TermEntry, { type SeeLink } from '@/components/terms/TermEntry.astro' +import type { TermKind } from '@/lib/constants/terms' +import { slugify } from '@/lib/content/slug' + +type Props = { + term: string + kind?: TermKind + see?: string +} + +const { term, kind = 'entry', see = '' } = Astro.props + +const links: SeeLink[] = see + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => ({ title: name, href: `#${slugify(name)}` })) +--- + + + + diff --git a/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro b/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro new file mode 100644 index 0000000..aad9b8f --- /dev/null +++ b/src/components/markdoc/blocks/general/Definitions/DefinitionList.astro @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/components/markdoc/blocks/general/FootnoteList.astro b/src/components/markdoc/blocks/general/FootnoteList.astro new file mode 100644 index 0000000..e4170b7 --- /dev/null +++ b/src/components/markdoc/blocks/general/FootnoteList.astro @@ -0,0 +1,25 @@ +--- +import Node from '@/components/markdoc/Node.astro' +import type { Footnote } from '@/lib/markdoc/types' + +type Props = { footnotes: Footnote[] } +const { footnotes } = Astro.props +--- + +{footnotes.length > 0 && ( +
+

Notes

+
    + {footnotes.map((footnote) => ( +
  1. +

    + {' '} + + ↩ + +

    +
  2. + ))} +
+
+)} diff --git a/src/components/markdoc/blocks/general/Lead.astro b/src/components/markdoc/blocks/general/Lead.astro new file mode 100644 index 0000000..a158932 --- /dev/null +++ b/src/components/markdoc/blocks/general/Lead.astro @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/components/markdoc/blocks/general/Quote.astro b/src/components/markdoc/blocks/general/Quote.astro new file mode 100644 index 0000000..651c9a9 --- /dev/null +++ b/src/components/markdoc/blocks/general/Quote.astro @@ -0,0 +1,22 @@ +--- +type Props = { + cite?: string + source?: string + href?: string +} + +const { cite, source, href } = Astro.props +const label = source || href +--- + +
+
+ +
+ {(cite || label) && ( +
+ {cite && {cite}} + {label && {href ? {label} : label}} +
+ )} +
diff --git a/src/components/markdoc/blocks/general/Reveal.astro b/src/components/markdoc/blocks/general/Reveal.astro new file mode 100644 index 0000000..ddc978d --- /dev/null +++ b/src/components/markdoc/blocks/general/Reveal.astro @@ -0,0 +1,20 @@ +--- +type Props = { summary?: string } + +const { summary = 'Spoiler' } = Astro.props +--- + +
+ + Spoiler + + + {summary} + Reveal + + + +
+ +
+
diff --git a/src/components/markdoc/blocks/general/TableWrapper.astro b/src/components/markdoc/blocks/general/TableWrapper.astro new file mode 100644 index 0000000..0727ee6 --- /dev/null +++ b/src/components/markdoc/blocks/general/TableWrapper.astro @@ -0,0 +1,15 @@ +--- +import type { TableVariant } from '@/lib/constants/blocks/table' + +type Props = { + variant?: TableVariant + caption?: string +} + +const { variant = 'default', caption } = Astro.props +--- + +
+ + {caption &&
{caption}
} +
diff --git a/src/components/media/ContentImage.astro b/src/components/media/ContentImage.astro new file mode 100644 index 0000000..9d1b909 --- /dev/null +++ b/src/components/media/ContentImage.astro @@ -0,0 +1,32 @@ +--- +import { Image } from 'astro:assets' + +import { resolveImage } from '@/lib/images/resolve' + +type Props = { + src: string + alt: string + eager?: boolean + widths?: number[] + sizes?: string + fit?: boolean +} + +const { src, alt, eager, widths, sizes, fit } = Astro.props +const image = resolveImage(src) +if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`) +--- + +{image ? ( + {alt} +) : ( + Missing image: {src} +)} diff --git a/src/components/media/Figure.astro b/src/components/media/Figure.astro new file mode 100644 index 0000000..df4ad5f --- /dev/null +++ b/src/components/media/Figure.astro @@ -0,0 +1,47 @@ +--- +import type { AspectRatio, FigureSize, ImageEffect } from '@/lib/constants/figure' + +import ContentImage from './ContentImage.astro' + +type Props = { + image: string + alt: string + caption?: string + credit?: string + size?: FigureSize + effect?: ImageEffect + ratio?: AspectRatio +} + +const LAYOUT: Record = { + inline: { widths: [400, 800, 1200], sizes: '(min-width: 80rem) 37rem, 100vw' }, + wide: { widths: [600, 1200, 1800], sizes: '(min-width: 80rem) 59rem, 100vw' }, +} + +const { + image, + alt, + caption, + credit, + size = 'inline', + effect = 'none', + ratio = 'original', +} = Astro.props +--- + +
+ + {(caption || credit) && ( +
+ {caption && {caption}} + {credit && {credit}} +
+ )} +
diff --git a/src/components/post/ArticleCard/ArticleCardGrid.astro b/src/components/post/ArticleCard/ArticleCardGrid.astro new file mode 100644 index 0000000..9494644 --- /dev/null +++ b/src/components/post/ArticleCard/ArticleCardGrid.astro @@ -0,0 +1,26 @@ +--- +import type { Article } from '@/lib/site/types' + +import ArticleCardItem from './ArticleCardItem.astro' + +type Props = { + articles: Article[] + title?: string +} + +const { articles, title } = Astro.props +const level = title ? 3 : 2 +--- + +{articles.length > 0 && ( +
+ {title &&

{title}

} +
    + {articles.map((article) => ( +
  • + +
  • + ))} +
+
+)} diff --git a/src/components/post/ArticleCard/ArticleCardItem.astro b/src/components/post/ArticleCard/ArticleCardItem.astro new file mode 100644 index 0000000..c7527a7 --- /dev/null +++ b/src/components/post/ArticleCard/ArticleCardItem.astro @@ -0,0 +1,41 @@ +--- +import ContentImage from '@/components/media/ContentImage.astro' +import { toMilitaryDTG } from '@/lib/content/dates' +import type { Article } from '@/lib/site/types' + +type Props = { + article: Article + level?: 2 | 3 +} + +const { article, level = 2 } = Astro.props +const { cover, excerpt } = article.summary +const date = article.publishedAt +const Heading = level === 2 ? 'h2' : 'h3' +--- + +
+ {cover.image && ( +
+ +
+ )} +
+ + + {article.title} + + + {excerpt &&

{excerpt}

} + {date && ( + + )} +
+
diff --git a/src/components/post/Footer/ArticleFooter.astro b/src/components/post/Footer/ArticleFooter.astro new file mode 100644 index 0000000..09623d3 --- /dev/null +++ b/src/components/post/Footer/ArticleFooter.astro @@ -0,0 +1,22 @@ +--- +import type { Sequence } from '@/lib/site/navigation' +import type { Article } from '@/lib/site/types' + +import RevisionCard from './RevisionCard.astro' +import SequenceNav from './SequenceNav.astro' + +type Props = { + article: Article + sequence: Sequence | null +} + +const { article, sequence } = Astro.props +const { title, changes } = article +--- + +{(changes.length > 0 || sequence) && ( +
+ {changes.length > 0 && } + {sequence && } +
+)} diff --git a/src/components/post/Footer/RevisionCard.astro b/src/components/post/Footer/RevisionCard.astro new file mode 100644 index 0000000..7e7cb9f --- /dev/null +++ b/src/components/post/Footer/RevisionCard.astro @@ -0,0 +1,38 @@ +--- +import { CHANGE_TYPES } from '@/lib/constants/changelog' +import { toMilitaryDTG } from '@/lib/content/dates' +import type { Change } from '@/lib/site/types' + +type Props = { + title: string + changes: Change[] +} + +const LIMIT = 5 + +const { title, changes } = Astro.props + +const labelOf = (kind: Change['kind']) => + CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind +--- + +
+
+

+ Revisions +

+

{title}

+
+
    + {changes.slice(0, LIMIT).map(({ kind, text, release }) => ( +
  1. + v{release.version} + + {labelOf(kind)} +

    {text}

    +
  2. + ))} +
+
diff --git a/src/components/post/Footer/SequenceNav.astro b/src/components/post/Footer/SequenceNav.astro new file mode 100644 index 0000000..ea6a004 --- /dev/null +++ b/src/components/post/Footer/SequenceNav.astro @@ -0,0 +1,66 @@ +--- +import { toRoman } from '@/lib/content/numerals' +import type { Sequence } from '@/lib/site/navigation' + +type Props = { + sequence: Sequence +} + +const { parent, siblings, position, previous, next } = Astro.props.sequence +--- + + diff --git a/src/components/post/Header/ArticleHeader.astro b/src/components/post/Header/ArticleHeader.astro new file mode 100644 index 0000000..b25baa6 --- /dev/null +++ b/src/components/post/Header/ArticleHeader.astro @@ -0,0 +1,32 @@ +--- +import { toMilitaryDTG } from '@/lib/content/dates' +import { trailOf } from '@/lib/site/navigation' +import type { Article } from '@/lib/site/types' + +import Cover from './Cover.astro' +import Meta from './Meta.astro' +import Overline from './Overline.astro' +import Title from './Title.astro' + +type Props = { + article: Article +} + +const { article } = Astro.props + +const crumbs = trailOf(article) +const published = article.publishedAt +--- + +
+ +
+ + <Meta article={article} /> + <Cover cover={article.summary.cover} /> + </div> +</header> diff --git a/src/components/post/Header/Cover.astro b/src/components/post/Header/Cover.astro new file mode 100644 index 0000000..24d7b8b --- /dev/null +++ b/src/components/post/Header/Cover.astro @@ -0,0 +1,34 @@ +--- +import ContentImage from '@/components/media/ContentImage.astro' +import type { Article } from '@/lib/site/types' + +type Props = { + cover: Article['summary']['cover'] +} + +const { image, alt, caption, credit, effect, ratio } = Astro.props.cover +--- + +{image && ( + <figure + class:list={[ + 'article-header__cover', + effect !== 'none' && `effect--${effect}`, + ratio !== 'original' && `ratio--${ratio}`, + ]} + > + <ContentImage + src={image} + alt={alt} + widths={[800, 1600, 2400]} + sizes="(min-width: 80rem) 74rem, 100vw" + eager + /> + {(caption || credit) && ( + <figcaption class="article-header__caption dark"> + {caption && <span>{caption}</span>} + {credit && <span class="article-header__credit">{credit}</span>} + </figcaption> + )} + </figure> +)} diff --git a/src/components/post/Header/Meta.astro b/src/components/post/Header/Meta.astro new file mode 100644 index 0000000..eb9d8d8 --- /dev/null +++ b/src/components/post/Header/Meta.astro @@ -0,0 +1,75 @@ +--- +import StageBadge from '@/components/post/Header/StageBadge.astro' +import { toMilitaryDTG } from '@/lib/content/dates' +import { getSite } from '@/lib/site' +import type { Article } from '@/lib/site/types' + +type Props = { + article: Article +} + +const { article } = Astro.props +const site = await getSite() +const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined) +const latest = article.changes[0]?.release.date +const updated = latest && article.publishedAt && latest > article.publishedAt ? latest : null +const date = updated ?? article.publishedAt +const dateLabel = updated ? 'Updated' : 'Published' +--- + +<dl class="article-header__meta"> + <div class="article-header__cell"> + <dt class="article-header__label"> + <span class="article-header__index" aria-hidden="true"> + 02 + </span> + State + </dt> + <dd> + <StageBadge stage={article.stage} /> + </dd> + </div> + <div class="article-header__cell"> + <dt class="article-header__label"> + <span class="article-header__index" aria-hidden="true"> + 03 + </span> + {dateLabel} + </dt> + <dd class="article-header__value"> + {date ? <time datetime={date.toISOString()}>{toMilitaryDTG(date)}</time> : 'Undated'} + </dd> + </div> + <div class="article-header__cell"> + <dt class="article-header__label"> + <span class="article-header__index" aria-hidden="true"> + 04 + </span> + Author + </dt> + <dd class="article-header__value">Dave Damage</dd> + </div> + <div class="article-header__cell"> + <dt class="article-header__label"> + <span class="article-header__index" aria-hidden="true"> + 05 + </span> + Tags + </dt> + <dd class="article-header__value"> + {tags.length > 0 ? ( + <ul class="article-header__tags"> + {tags.map((tag) => ( + <li> + <a class="article-header__tag" href={tag.path} rel="tag"> + {tag.title} + </a> + </li> + ))} + </ul> + ) : ( + 'None' + )} + </dd> + </div> +</dl> diff --git a/src/components/post/Header/Overline.astro b/src/components/post/Header/Overline.astro new file mode 100644 index 0000000..c9dbdab --- /dev/null +++ b/src/components/post/Header/Overline.astro @@ -0,0 +1,95 @@ +--- +export type Crumb = { + title: string + path?: string +} + +type Props = { + crumbs: Crumb[] + stamp?: string + datetime?: string +} + +const { crumbs, stamp, datetime } = Astro.props +--- + +<div class="overline dark"> + <div class="wrapper"> + {crumbs.length > 1 && ( + <nav aria-label="Breadcrumb" class="breadcrumbs"> + <ol> + {crumbs.map((crumb, index) => ( + <li> + {crumb.path && index < crumbs.length - 1 ? ( + <a href={crumb.path}>{crumb.title}</a> + ) : ( + <span aria-current="page">{crumb.title}</span> + )} + </li> + ))} + </ol> + </nav> + )} + {datetime ? ( + <time class="stamp" datetime={datetime}> + <slot name="stamp">{stamp}</slot> + </time> + ) : ( + <span class="stamp"> + <slot name="stamp">{stamp}</slot> + </span> + )} + </div> +</div> + +<style> + .overline { + font-family: var(--font-mono); + font-size: var(--type--1); + line-height: 1; + color: var(--color-text-secondary); + letter-spacing: var(--tracking-0); + + background-color: var(--color-surface-elevated-2); + } + + .wrapper { + @mixin layout-wrapper; + @mixin py var(--size-4); + + display: flex; + gap: var(--size-4); + align-items: baseline; + justify-content: space-between; /* breadcrumbs left, stamp right */ + } + + .breadcrumbs { + & ol { + display: flex; + gap: var(--size-2); + + margin: 0; + padding: 0; + + list-style: none; + + & li { + &:not(:last-child) { + &::after { + content: '⟫' / ''; + margin-inline-start: var(--size-2); + } + } + } + } + } + + .stamp { + font-weight: 700; + + &::before { + content: '⟫' / ''; + margin-inline-end: var(--size-2); + } + } +</style> diff --git a/src/components/post/Header/SimpleHeader.astro b/src/components/post/Header/SimpleHeader.astro new file mode 100644 index 0000000..e4f8049 --- /dev/null +++ b/src/components/post/Header/SimpleHeader.astro @@ -0,0 +1,28 @@ +--- +type Props = { + title: string + eyebrow?: string + standfirst?: string + count?: number +} + +const { title, eyebrow, standfirst, count } = Astro.props +--- + +<header class="simple-header dark"> + <div class="simple-header__inner"> + <div class="simple-header__text"> + {eyebrow && <p class="simple-header__eyebrow">{eyebrow}</p>} + <h1 class="simple-header__title">{title}</h1> + {standfirst && <p class="simple-header__standfirst">{standfirst}</p>} + </div> + {count !== undefined && ( + <p class="simple-header__count"> + <span aria-hidden="true">{String(count).padStart(2, '0')}</span> + <span class="simple-header__hidden"> + {count} {count === 1 ? 'entry' : 'entries'} + </span> + </p> + )} + </div> +</header> diff --git a/src/components/post/Header/StageBadge.astro b/src/components/post/Header/StageBadge.astro new file mode 100644 index 0000000..1daed1d --- /dev/null +++ b/src/components/post/Header/StageBadge.astro @@ -0,0 +1,13 @@ +--- +import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage' + +type Props = { + stage: Stage +} + +const { stage } = Astro.props + +const label = STAGE_OPTIONS.find((option) => option.value === stage)?.label ?? stage +--- + +<span class:list={['stage-badge', `stage-badge--${stage}`]}>{label}</span> diff --git a/src/components/post/Header/Title.astro b/src/components/post/Header/Title.astro new file mode 100644 index 0000000..27e6cc0 --- /dev/null +++ b/src/components/post/Header/Title.astro @@ -0,0 +1,17 @@ +--- +type Props = { + title: string + subtitle?: string +} + +const { title, subtitle } = Astro.props +--- + +<div class="article-header__designation"> + <p class="article-header__label" aria-hidden="true"> + <span class="article-header__index">01</span> + Designation + </p> + <h1 class="article-header__title">{title}</h1> + {subtitle && <p class="article-header__subtitle">{subtitle}</p>} +</div> diff --git a/src/components/post/ToC.astro b/src/components/post/ToC.astro new file mode 100644 index 0000000..ffcba8a --- /dev/null +++ b/src/components/post/ToC.astro @@ -0,0 +1,99 @@ +--- +import type { Heading } from '@/lib/markdoc/types' + +type Props = { + headings: Heading[] + min?: number + level?: number +} + +const { headings, min = 3, level = 3 } = Astro.props +const entries = headings.filter((heading) => heading.level <= level) +--- + +{entries.length >= min && ( + <nav class="toc" aria-labelledby="toc-title"> + <h2 class="title" id="toc-title"> + Contents + </h2> + <ol> + {entries.map((heading) => ( + <li class:list={['entry', `entry--${heading.level}`]}> + <a href={`#${heading.id}`}> + <span class="n" aria-hidden="true" /> + <span class="label">{heading.text}</span> + </a> + </li> + ))} + </ol> + </nav> +)} + +<style> + .toc { + @mixin border-t var(--border-card), solid, var(--color-border-strong); + counter-reset: toc; + position: sticky; + top: calc(var(--layout-header-height) + var(--size-10)); + } + + .title { + @mixin typo_label; + @mixin my var(--size-0); + @mixin py var(--size-4); + @mixin border-b var(--border-hairline), solid, var(--color-border-normal); + font-size: var(--type--2); + } + + ol { + list-style: none; + } + + a { + @mixin py var(--size-3); + @mixin border-b var(--border-hairline), solid, var(--color-border-subtle); + display: grid; + grid-template-columns: 4ch minmax(0, 1fr); + + font-family: var(--font-mono); + font-size: var(--type--1); + line-height: var(--leading-2); + color: var(--color-text-primary); + text-decoration: none; + + &:hover, + &:focus-visible { + background-color: var(--color-surface-hover); + } + } + + .n { + color: var(--color-text-quiet); + } + + .entry--2 { + counter-increment: toc; + + & .n { + &::before { + content: counter(toc, upper-roman); + } + } + } + + .entry--3 { + & a { + @mixin ps var(--size-8); + } + + & .label { + color: var(--color-text-secondary); + } + + & .n { + &::before { + content: '⟫'; + } + } + } +</style> diff --git a/src/components/site/Finder.astro b/src/components/site/Finder.astro new file mode 100644 index 0000000..659c63f --- /dev/null +++ b/src/components/site/Finder.astro @@ -0,0 +1,140 @@ +--- +import { COLLECTION_LABELS } from '@/lib/constants/collections' +import { toRoman } from '@/lib/content/numerals' +import { getSite } from '@/lib/site' +import { contextOf } from '@/lib/site/navigation' +import type { Article } from '@/lib/site/types' + +type Props = { + article?: Article +} + +const { article } = Astro.props +const site = await getSite() +const groups = contextOf(article ?? null, site.roots) +const count = groups.reduce((sum, group) => sum + group.entries.length, 0) +--- + +<site-finder class="finder"> + <button + class="finder__trigger light" + type="button" + commandfor="finder" + command="show-modal" + aria-haspopup="dialog" + > + <span class="finder__prompt" aria-hidden="true"> + ❯ + </span> + <span class="finder__placeholder">Navigate…</span> + <kbd class="finder__key" data-finder-shortcut> + ⌘K + </kbd> + </button> + + <dialog id="finder" class="finder__dialog" aria-labelledby="finder-title" closedby="any"> + <div class="finder__bar"> + <h2 id="finder-title" class="finder__title"> + Register<span class="finder__crumb">Query</span> + </h2> + <button + class="finder__close" + type="button" + commandfor="finder" + command="close" + aria-label="Close" + > + Esc + </button> + </div> + + <div class="finder__field"> + <span class="finder__prompt" aria-hidden="true"> + ❯ + </span> + <input + class="finder__input" + type="text" + role="combobox" + aria-label="Search articles" + aria-controls="finder-results" + aria-expanded="true" + aria-autocomplete="list" + placeholder="Where do you want to go?" + autocomplete="off" + spellcheck="false" + data-finder-input + autofocus + /> + </div> + + <div class="finder__status"> + <p class="finder__scope" data-finder-scope> + <span data-finder-mode>Context</span> + <span class="finder__crumb" data-finder-subject> + {article?.title ?? 'Index'} + </span> + </p> + <p class="finder__count" data-finder-count> + {String(count).padStart(2, '0')} entries + </p> + </div> + + <div id="finder-results" class="finder__results" role="listbox" aria-label="Articles"> + <div data-finder-context> + {groups.map((group, g) => { + const heading = group.heading?.title ?? 'Index' + return ( + <div class="finder__group" role="group" aria-label={heading}> + <p class="finder__heading" aria-hidden="true"> + <span class="finder__section">§ {toRoman(g + 1)}</span> + {heading} + </p> + {group.entries.map((entry, i) => ( + <a + id={`finder-option-${g}-${i}`} + class="finder__row" + href={entry.path} + role="option" + tabindex="-1" + > + <span class="finder__type">{COLLECTION_LABELS[entry.collection]}</span> + <span class="finder__label">{entry.title}</span> + <span class="finder__path">{entry.path}</span> + </a> + ))} + </div> + ) + })} + </div> + <div data-finder-matches hidden></div> + <p class="finder__empty" data-finder-empty hidden> + No entries found + </p> + + <template data-finder-row> + <a class="finder__row" role="option" tabindex="-1"> + <span class="finder__type" data-field="type"></span> + <span class="finder__label" data-field="label"></span> + <span class="finder__path" data-field="path"></span> + </a> + </template> + </div> + + <div class="finder__footer" aria-hidden="true"> + <span class="finder__hint"> + <kbd>↑</kbd> + <kbd>↓</kbd> Move + </span> + <span class="finder__hint"> + <kbd>↵</kbd> Open + </span> + <span class="finder__hint"> + <kbd data-finder-shortcut>⌘K</kbd> Toggle + </span> + </div> + </dialog> +</site-finder> +<script> + import '@/lib/finder/element' +</script> diff --git a/src/components/site/SiteFooter.astro b/src/components/site/SiteFooter.astro new file mode 100644 index 0000000..60834d1 --- /dev/null +++ b/src/components/site/SiteFooter.astro @@ -0,0 +1,39 @@ +--- +import { toMilitaryDTG } from '@/lib/content/dates' +import { getSite } from '@/lib/site' + +const LINKED_PAGES = ['imprint', 'privacy-policy', 'colophon'] + +const site = await getSite() +const release = site.releases[0] +const pages = LINKED_PAGES.map((id) => site.pages.get(id)).filter((page) => page !== undefined) +const year = new Date().getUTCFullYear() +--- + +<footer class="site-footer dark"> + <div class="site-footer__inner"> + <p class="site-footer__copyright">© {year} Dave Damage</p> + {release && ( + <p class="site-footer__release"> + <span class="site-footer__version">v{release.version}</span> + <time datetime={release.date.toISOString()}>{toMilitaryDTG(release.date)}</time> + </p> + )} + <nav class="site-footer__nav" aria-label="Site"> + <ul class="site-footer__links"> + {pages.map((page) => ( + <li> + <a class="site-footer__link" href={page.path}> + {page.title} + </a> + </li> + ))} + <li> + <a class="site-footer__link site-footer__top" href="#top"> + Top + </a> + </li> + </ul> + </nav> + </div> +</footer> diff --git a/src/components/site/SiteHeader.astro b/src/components/site/SiteHeader.astro new file mode 100644 index 0000000..b508f23 --- /dev/null +++ b/src/components/site/SiteHeader.astro @@ -0,0 +1,26 @@ +--- +import type { Article } from '@/lib/site/types' + +import Finder from './Finder.astro' + +type Props = { + article?: Article +} + +const { article } = Astro.props +--- + +<header class="site-header dark"> + <div class="site-header__inner"> + <a class="site-header__home" href="/" aria-label="dave dmg, home"> + <span class="site-header__logo" aria-hidden="true"> + ◬ + </span> + <span class="site-header__name"> + dave<span class="site-header__bracket">[</span>dmg + <span class="site-header__bracket">]</span> + </span> + </a> + <Finder article={article} /> + </div> +</header> diff --git a/src/components/terms/TermEntry.astro b/src/components/terms/TermEntry.astro new file mode 100644 index 0000000..826c903 --- /dev/null +++ b/src/components/terms/TermEntry.astro @@ -0,0 +1,42 @@ +--- +import { TERM_KINDS, type TermKind } from '@/lib/constants/terms' + +export type SeeLink = { + title: string + href: string +} + +type Props = { + id: string + title: string + kind: TermKind + see?: SeeLink[] + ink?: boolean +} + +const { id, title, kind, see = [], ink } = Astro.props + +const label = TERM_KINDS.find((option) => option.value === kind)?.label ?? kind +--- + +<dt class:list={['term', ink && 'dark']} id={id}> + <span class="term__name">{title}</span> + <span class="term__kind">{label}</span> +</dt> + +<dd class="term__body"> + <p class="term__definition"> + <slot /> + </p> + + {see.length > 0 && ( + <p class="term__see"> + <span class="term__see-label">See</span> + {see.map((related) => ( + <a class="term__link" href={related.href}> + {related.title} + </a> + ))} + </p> + )} +</dd> diff --git a/src/content.config.ts b/src/content.config.ts deleted file mode 100644 index e0f9af2..0000000 --- a/src/content.config.ts +++ /dev/null @@ -1 +0,0 @@ -export const collections = {} diff --git a/src/content/general/articles/awq.mdoc b/src/content/general/articles/awq.mdoc new file mode 100644 index 0000000..960fecd --- /dev/null +++ b/src/content/general/articles/awq.mdoc @@ -0,0 +1,15 @@ +--- +title: Advanced Warhammer Quest +status: draft +noindex: false +stage: stub +route: + index: 3 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T16:46:00.000Z +tags: [] +--- diff --git a/src/content/general/articles/calcination.mdoc b/src/content/general/articles/calcination.mdoc new file mode 100644 index 0000000..9d91ced --- /dev/null +++ b/src/content/general/articles/calcination.mdoc @@ -0,0 +1,20 @@ +--- +title: Calcination +subtitle: Where we burn the people +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 2 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/chainbreaker.mdoc b/src/content/general/articles/chainbreaker.mdoc new file mode 100644 index 0000000..5b7ffa2 --- /dev/null +++ b/src/content/general/articles/chainbreaker.mdoc @@ -0,0 +1,16 @@ +--- +title: Chainbreaker +subtitle: We're gonna break down the walls +status: draft +noindex: false +stage: stub +route: + index: 2 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T14:27:00.000Z +tags: [] +--- diff --git a/src/content/general/articles/coagulation.mdoc b/src/content/general/articles/coagulation.mdoc new file mode 100644 index 0000000..ccf4937 --- /dev/null +++ b/src/content/general/articles/coagulation.mdoc @@ -0,0 +1,20 @@ +--- +title: Coagulation +subtitle: Where we burn the politicians +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 6 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/conjunction.mdoc b/src/content/general/articles/conjunction.mdoc new file mode 100644 index 0000000..79c2dec --- /dev/null +++ b/src/content/general/articles/conjunction.mdoc @@ -0,0 +1,20 @@ +--- +title: Conjunction +subtitle: Where we burn the state +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 5 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/dissolution.mdoc b/src/content/general/articles/dissolution.mdoc new file mode 100644 index 0000000..d81310a --- /dev/null +++ b/src/content/general/articles/dissolution.mdoc @@ -0,0 +1,20 @@ +--- +title: Dissolution +subtitle: Where we act +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 7 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/fermentation.mdoc b/src/content/general/articles/fermentation.mdoc new file mode 100644 index 0000000..023b660 --- /dev/null +++ b/src/content/general/articles/fermentation.mdoc @@ -0,0 +1,20 @@ +--- +title: Fermentation +subtitle: Where we burn the priests +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 3 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/prima-materia.mdoc b/src/content/general/articles/prima-materia.mdoc new file mode 100644 index 0000000..9990edb --- /dev/null +++ b/src/content/general/articles/prima-materia.mdoc @@ -0,0 +1,212 @@ +--- +title: Prima Materia +subtitle: Where we lay the ground +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 1 + sequence: false +summary: + cover: + image: /src/assets/content/articles/prima-materia/summary/cover/image.jpg + alt: The Great Day of His Wrath + credit: John Martin – The Great Day of His Wrath + effect: xeroxed + ratio: 21-9 +published: 2026-09-21T12:35:00.000Z +tags: + - the-crucible + - alchemical-materialism + - genesis +--- +{% lead %} +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This first paragraph is the lead, so it should be larger and carry the drop cap. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. A sentence with *emphasis*, **strong text**, `inline code`, ~~struck text~~ and an [external link](https://example.com). +{% /lead %} + +## Heading two, long enough to wrap onto a second line + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur{% footnote note="A footnote with *emphasis* and a [link](https://example.com)." /%}. Excepteur sint occaecat cupidatat non proident{% sidenote type="default" marker="†" note="A sidenote in the margin, beside the line it belongs to." /%}. + +### Heading three + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam. + +#### Heading four + +At vero eos et accusamus et iusto odio dignissimos ducimus. + +## Lists + +- First item +- Second item, with enough text to run past the end of a line and wrap underneath the marker +- Third item + - Nested item + - Another nested item + - Third level + +1. First step +1. Second step + 1. Sub-step + 1. Another sub-step +1. Third step + +## Quotes and rules + +> A plain blockquote, no attribution. +> +> With a second paragraph. + +{% quote + cite="Paracelsus" + source="Die dritte Defension" + href="https://example.com" %} +All things are poison, and nothing is without poison; the dosage alone makes it so a thing is not a poison. +{% /quote %} + +--- + +## Callouts + +{% callout type="default" title="TITA TITLE" %} +I AM DONE WITH THIS SHIT! SO COMPLETELY AND TOTALLY DONE! +{% /callout %} + +{% callout type="optional" title="Note" %} +A default callout with a [link](https://example.com) and `code` inside it. +{% /callout %} + +{% callout type="example" title="Worked example" %} +An example callout containing a list: + +- one +- two +{% /callout %} + +{% reveal summary="Spoiler" %} +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This first paragraph is the lead, so it should be larger and carry the drop cap. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. A sentence with *emphasis*, **strong text**, `inline code`, ~~struck text~~ and an [external link](https://example.com). +{% /reveal %} + +## Code + +```js +const crucible = { stage: 'prima-materia', heat: 3 } + +for (const [key, value] of Object.entries(crucible)) { + console.log(`${key}: ${value}`) +} +``` + +## Tables + +{% tableWrapper + variant="default" + caption="Stages of the Work" + columns=["rowheader", "key", "num", "num", "text"] %} +{% table %} +- Stage +- No +- Heat +- Hours +- Notes +--- +- Prima Materia +- I +- 3 +- 0.25 +- The undifferentiated ground; nothing is named yet. +--- +- Calcination +- II +- 7 +- 1.50 +- Burned down to what will not burn. +--- +- Solutio +- III +- 5 +- 12 +- Dissolved back into flow; forms loosen. +--- +- Tincture +- VII +- 10 +- 120.75 +- The finished colour, carried into everything it touches. +{% /table %} +{% /tableWrapper %} + +## Images + +{% figure + image="/src/assets/content/articles/prima-materia/john_martin_-_the_great_day_of_his_wrath_-_google_art_project.jpg" + alt="A placeholder image" + caption="An inline figure with a caption" + credit="Dave Damage" + effect="xeroxed" + ratio="16-9" + size="inline" /%} + +Aliquam erat volutpat. Fusce sed velit at arcu tincidunt vulputate{% sidenote type="example" marker="‡" note="A second sidenote, typed as an example." /%}. Nam vitae lacus a nisl ultrices dictum. + +Finally, a link to another article: {% xref reference="the-crucible" %}The Crucible{% /xref %}. + +## Code + +```ts +const crucible = { stage: 'prima-materia', heat: 3 } + +function temper(work: typeof crucible): 'calcined' | 'raw' { + return work.heat > 5 ? 'calcined' : 'raw' +} +``` + +A fence with no language, which should render unhighlighted and with no label: + +``` +Prima materia — the undifferentiated ground. +Nothing is named yet. +``` + +A long line, which should scroll inside the block rather than widen the article: + +```css +.prose :is(h2, h3, h4) + .table-wrapper thead th { border-block-end: var(--border-card) solid var(--color-border-strong); color: inherit; background-color: transparent; } +``` + +An unknown language, which should render plain and raise a warning in the dev banner: + +```brainfuck ++[----->+++<]>+. +``` + +{% callout type="example" title="Code inside a block" %} +The frame should sit inside the callout body, not fight it: + +```js +export const isTag = (node) => Markdoc.Tag.isTag(node) +``` +{% /callout %} + +## Spoiler + +Duis aute irure dolor in reprehenderit, and then {% spoiler %}the prima materia was the vessel all along{% /spoiler %}, which changes the reading of everything above. + +A spoiler long enough to wrap across a line break: {% spoiler %}Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua{% /spoiler %}. And text after it. + +## Definitions + +{% definitions %} +{% definition term="Prima Materia" kind="prime" see="Calcination, Solutio" %} +The undifferentiated ground. Nothing is named yet, which is precisely what makes it workable. +{% /definition %} + +{% definition term="Calcination" kind="stage" see="Prima Materia" %} +Burned down to what will not burn. +{% /definition %} + +{% definition term="Solutio" kind="stage" %} +Dissolved back into flow, so that forms loosen. +{% /definition %} +{% /definitions %} diff --git a/src/content/general/articles/stages.mdoc b/src/content/general/articles/stages.mdoc new file mode 100644 index 0000000..2e0a118 --- /dev/null +++ b/src/content/general/articles/stages.mdoc @@ -0,0 +1,15 @@ +--- +title: Stages +status: draft +noindex: false +stage: stub +route: + parent: the-crucible + sequence: true +summary: + cover: + effect: none + ratio: original +published: 2026-09-21T12:34:00.000Z +tags: [] +--- diff --git a/src/content/general/articles/sublimation.mdoc b/src/content/general/articles/sublimation.mdoc new file mode 100644 index 0000000..686d91a --- /dev/null +++ b/src/content/general/articles/sublimation.mdoc @@ -0,0 +1,20 @@ +--- +title: Sublimation +subtitle: Where we burn the witches +status: draft +noindex: false +stage: stub +route: + parent: stages + index: 4 + sequence: false +summary: + cover: + effect: none + ratio: original +published: 2026-09-30T12:27:00.000Z +tags: + - alchemical-materialism + - genesis + - the-crucible +--- diff --git a/src/content/general/articles/the-crucible.mdoc b/src/content/general/articles/the-crucible.mdoc new file mode 100644 index 0000000..8652269 --- /dev/null +++ b/src/content/general/articles/the-crucible.mdoc @@ -0,0 +1,20 @@ +--- +title: The Crucible +status: draft +noindex: false +stage: stub +route: + index: 1 + sequence: false +summary: + excerpt: >- + Where we explore the crucible – a materialistic alchemist Worldbuilding + Framework + cover: + effect: none + ratio: original +published: 2026-09-21T12:33:00.000Z +tags: + - the-crucible +--- +hmm, do you even sharp? diff --git a/src/content/general/pages/colophon.mdoc b/src/content/general/pages/colophon.mdoc new file mode 100644 index 0000000..0ecd882 --- /dev/null +++ b/src/content/general/pages/colophon.mdoc @@ -0,0 +1,7 @@ +--- +title: Colophon +status: draft +noindex: false +route: {} +summary: {} +--- diff --git a/src/content/general/pages/imprint.mdoc b/src/content/general/pages/imprint.mdoc new file mode 100644 index 0000000..c88f5a6 --- /dev/null +++ b/src/content/general/pages/imprint.mdoc @@ -0,0 +1,8 @@ +--- +title: Imprint +status: draft +noindex: false +route: {} +summary: {} +--- +Set in Blaka{% footnote note="Jdajhdasljkh" /%}. diff --git a/src/content/general/pages/privacy-policy.mdoc b/src/content/general/pages/privacy-policy.mdoc new file mode 100644 index 0000000..bed757b --- /dev/null +++ b/src/content/general/pages/privacy-policy.mdoc @@ -0,0 +1,7 @@ +--- +title: Privacy Policy +status: draft +noindex: false +route: {} +summary: {} +--- diff --git a/src/content/general/tags/alchemical-materialism.json b/src/content/general/tags/alchemical-materialism.json new file mode 100644 index 0000000..6e8c4d4 --- /dev/null +++ b/src/content/general/tags/alchemical-materialism.json @@ -0,0 +1,3 @@ +{ + "title": "Alchemical Materialism" +} diff --git a/src/content/general/tags/genesis.json b/src/content/general/tags/genesis.json new file mode 100644 index 0000000..c650233 --- /dev/null +++ b/src/content/general/tags/genesis.json @@ -0,0 +1,3 @@ +{ + "title": "Genesis" +} diff --git a/src/content/general/tags/the-crucible.json b/src/content/general/tags/the-crucible.json new file mode 100644 index 0000000..1644c52 --- /dev/null +++ b/src/content/general/tags/the-crucible.json @@ -0,0 +1,4 @@ +{ + "title": "The Crucible", + "description": "I AM JUST A ITSY-BITSY TEENY-WEENIE TEST" +} diff --git a/src/content/site/releases/v0-12.yaml b/src/content/site/releases/v0-12.yaml new file mode 100644 index 0000000..5d67b2c --- /dev/null +++ b/src/content/site/releases/v0-12.yaml @@ -0,0 +1,6 @@ +version: '0.12' +date: 2026-09-30T13:00:00.000Z +changes: + - kind: added + article: prima-materia + text: Section on the ground state; first table of correspondences diff --git a/src/content/site/releases/v0-13.yaml b/src/content/site/releases/v0-13.yaml new file mode 100644 index 0000000..45fbe48 --- /dev/null +++ b/src/content/site/releases/v0-13.yaml @@ -0,0 +1,12 @@ +version: '0.13' +date: 2026-09-30T13:00:00.000Z +changes: + - kind: added + article: calcination + text: Test + - kind: added + article: fermentation + text: Big test with a longer sentence + - kind: changed + article: coagulation + text: Lorem ipsum, blablabla ipbsum whorum diff --git a/src/content/site/releases/v0-14.yaml b/src/content/site/releases/v0-14.yaml new file mode 100644 index 0000000..c3c5176 --- /dev/null +++ b/src/content/site/releases/v0-14.yaml @@ -0,0 +1,19 @@ +version: '0.14' +date: 2026-09-30T13:00:00.000Z +changes: + - kind: added + article: coagulation + text: >- + s the raw release exactly as Keystatic reads it, inferred from the schema + like the other three + - kind: changed + article: prima-materia + text: >- + releases are loaded, resolved and attached to articles. Six files; nothing + renders yet. + - kind: fixed + article: fermentation + text: We don't burn the priests anymore, we burn the gods + - kind: removed + article: the-crucible + text: AND EVERYTHING GOES BOOOOOOOM diff --git a/src/integrations/content-reload.ts b/src/integrations/content-reload.ts new file mode 100644 index 0000000..12d32d1 --- /dev/null +++ b/src/integrations/content-reload.ts @@ -0,0 +1,18 @@ +import type { AstroIntegration } from 'astro' + +export const contentReload = (): AstroIntegration => ({ + name: 'content-reload', + hooks: { + 'astro:server:setup': ({ server }) => { + server.watcher.on('all', (_event, file) => { + if (!file.includes('/src/content')) return + for (const name of ['ssr', 'prerender']) + server.environments[name]?.hot.send('astro:content-changed', {}) + server.environments.client.hot.send({ + type: 'full-reload', + path: '*', + }) + }) + }, + }, +}) diff --git a/src/keystatic/blocks/general/callout.ts b/src/keystatic/blocks/general/callout.ts new file mode 100644 index 0000000..8c6135d --- /dev/null +++ b/src/keystatic/blocks/general/callout.ts @@ -0,0 +1,19 @@ +import { fields } from '@keystatic/core' +import { wrapper } from '@keystatic/core/content-components' + +import { NOTE_TYPES } from '@/lib/constants/blocks/notes' + +export const callout = wrapper({ + label: 'Callout', + description: 'An aside around any content', + schema: { + type: fields.select({ + label: 'Type', + options: NOTE_TYPES, + defaultValue: 'default', + }), + title: fields.text({ + label: 'Title', + }), + }, +}) diff --git a/src/keystatic/blocks/general/definition-list/definition.ts b/src/keystatic/blocks/general/definition-list/definition.ts new file mode 100644 index 0000000..d143974 --- /dev/null +++ b/src/keystatic/blocks/general/definition-list/definition.ts @@ -0,0 +1,27 @@ +import { fields } from '@keystatic/core' +import { wrapper } from '@keystatic/core/content-components' + +import { TERM_KINDS } from '@/lib/constants/terms' + +export const definition = wrapper({ + label: 'Definition', + description: 'One term and its meaning', + forSpecificLocations: true, + schema: { + term: fields.text({ + label: 'Term', + validation: { + isRequired: true, + }, + }), + kind: fields.select({ + label: 'Kind', + options: TERM_KINDS, + defaultValue: 'entry', + }), + see: fields.text({ + label: 'See also', + description: 'Other terms in this list, comma separated', + }), + }, +}) diff --git a/src/keystatic/blocks/general/definition-list/definitions.ts b/src/keystatic/blocks/general/definition-list/definitions.ts new file mode 100644 index 0000000..5207e3c --- /dev/null +++ b/src/keystatic/blocks/general/definition-list/definitions.ts @@ -0,0 +1,13 @@ +import { repeating } from '@keystatic/core/content-components' + +export const definitions = repeating({ + label: 'Definitions', + description: 'A list of terms defined for this article only', + children: ['definition'], + validation: { + children: { + min: 1, + }, + }, + schema: {}, +}) diff --git a/src/keystatic/blocks/general/figure.ts b/src/keystatic/blocks/general/figure.ts new file mode 100644 index 0000000..8eabf32 --- /dev/null +++ b/src/keystatic/blocks/general/figure.ts @@ -0,0 +1,21 @@ +import { fields } from '@keystatic/core' +import { block } from '@keystatic/core/content-components' + +import { imageFields } from '@/keystatic/fields/content/image' +import { FIGURE_SIZES } from '@/lib/constants/figure' + +export const figure = (collection: string) => + block({ + label: 'Figure', + description: 'An image with alt text, and optionally a caption and credit.', + schema: { + ...imageFields(collection, { + required: true, + }), + size: fields.select({ + label: 'Size', + options: FIGURE_SIZES, + defaultValue: 'inline', + }), + }, + }) diff --git a/src/keystatic/blocks/general/footnote.ts b/src/keystatic/blocks/general/footnote.ts new file mode 100644 index 0000000..ed8f7b2 --- /dev/null +++ b/src/keystatic/blocks/general/footnote.ts @@ -0,0 +1,16 @@ +import { fields } from '@keystatic/core' +import { inline } from '@keystatic/core/content-components' + +export const footnote = inline({ + label: 'Footnote', + description: 'Numbered automatically; the note is collected at the end of the article', + schema: { + note: fields.text({ + label: 'Note', + multiline: true, + validation: { + isRequired: true, + }, + }), + }, +}) diff --git a/src/keystatic/blocks/general/index.ts b/src/keystatic/blocks/general/index.ts new file mode 100644 index 0000000..c439b15 --- /dev/null +++ b/src/keystatic/blocks/general/index.ts @@ -0,0 +1,27 @@ +import { callout } from './callout' +import { definition } from './definition-list/definition' +import { definitions } from './definition-list/definitions' +import { figure } from './figure' +import { footnote } from './footnote' +import { lead } from './lead' +import { quote } from './quote' +import { reveal } from './reveal' +import { sidenote } from './sidenote' +import { spoiler } from './spoiler' +import { tableWrapper } from './table-wrapper' +import { xref } from './xref' + +export const generalBlocks = (collection: string) => ({ + figure: figure(collection), + callout, + definition, + definitions, + lead, + footnote, + sidenote, + tableWrapper, + quote, + reveal, + spoiler, + xref: xref(), +}) diff --git a/src/keystatic/blocks/general/lead.ts b/src/keystatic/blocks/general/lead.ts new file mode 100644 index 0000000..4760733 --- /dev/null +++ b/src/keystatic/blocks/general/lead.ts @@ -0,0 +1,7 @@ +import { wrapper } from '@keystatic/core/content-components' + +export const lead = wrapper({ + label: 'Lead', + description: 'The opening paragraph, set larger and with the drop cap', + schema: {}, +}) diff --git a/src/keystatic/blocks/general/quote.ts b/src/keystatic/blocks/general/quote.ts new file mode 100644 index 0000000..bc5be3a --- /dev/null +++ b/src/keystatic/blocks/general/quote.ts @@ -0,0 +1,17 @@ +import { fields } from '@keystatic/core' +import { wrapper } from '@keystatic/core/content-components' + +export const quote = wrapper({ + label: 'Quote', + schema: { + cite: fields.text({ + label: 'Cite', + }), + source: fields.text({ + label: 'Source', + }), + href: fields.url({ + label: 'Source Link', + }), + }, +}) diff --git a/src/keystatic/blocks/general/reveal.ts b/src/keystatic/blocks/general/reveal.ts new file mode 100644 index 0000000..6980079 --- /dev/null +++ b/src/keystatic/blocks/general/reveal.ts @@ -0,0 +1,14 @@ +import { fields } from '@keystatic/core' +import { wrapper } from '@keystatic/core/content-components' + +export const reveal = wrapper({ + label: 'Reveal', + description: 'A spoiler around any content, closed until the reader opens it', + schema: { + summary: fields.text({ + label: 'Label', + description: 'What the reader sees before opening.', + defaultValue: 'Spoiler', + }), + }, +}) diff --git a/src/keystatic/blocks/general/sidenote.ts b/src/keystatic/blocks/general/sidenote.ts new file mode 100644 index 0000000..19232b0 --- /dev/null +++ b/src/keystatic/blocks/general/sidenote.ts @@ -0,0 +1,33 @@ +import { fields } from '@keystatic/core' +import { inline } from '@keystatic/core/content-components' + +import { NOTE_TYPES } from '@/lib/constants/blocks/notes' + +export const sidenote = inline({ + label: 'Sidenote', + schema: { + type: fields.select({ + label: 'Type', + options: NOTE_TYPES, + defaultValue: 'default', + }), + marker: fields.text({ + label: 'Marker', + description: 'One or two characters, e.g. * † ‡ a.', + defaultValue: '▾', + validation: { + isRequired: true, + length: { + max: 3, + }, + }, + }), + note: fields.text({ + label: 'Note', + multiline: true, + validation: { + isRequired: true, + }, + }), + }, +}) diff --git a/src/keystatic/blocks/general/spoiler.ts b/src/keystatic/blocks/general/spoiler.ts new file mode 100644 index 0000000..96f6eba --- /dev/null +++ b/src/keystatic/blocks/general/spoiler.ts @@ -0,0 +1,15 @@ +import { mark } from '@keystatic/core/content-components' + +import { eyeOffIcon } from '@keystar/ui/icon/icons/eyeOffIcon' + +export const spoiler = mark({ + label: 'Spoiler', + icon: eyeOffIcon, + tag: 'span', + schema: {}, + style: { + backgroundColor: '#2A2A2A', + color: '#F5F5F5', + padding: '0 2px', + }, +}) diff --git a/src/keystatic/blocks/general/table-wrapper.ts b/src/keystatic/blocks/general/table-wrapper.ts new file mode 100644 index 0000000..b0a0a01 --- /dev/null +++ b/src/keystatic/blocks/general/table-wrapper.ts @@ -0,0 +1,31 @@ +import { fields } from '@keystatic/core' +import { wrapper } from '@keystatic/core/content-components' + +import { COLUMN_TYPES, TABLE_VARIANTS } from '@/lib/constants/blocks/table' + +export const tableWrapper = wrapper({ + label: 'Table Wrapper', + description: 'Styling and caption for one table', + schema: { + variant: fields.select({ + label: 'Variant', + options: TABLE_VARIANTS, + defaultValue: 'default', + }), + caption: fields.text({ + label: 'Caption', + }), + columns: fields.array( + fields.select({ + label: 'Type', + options: COLUMN_TYPES, + defaultValue: 'default', + }), + { + label: 'Columns', + description: 'One per column, left to right.', + itemLabel: (item) => item.value, + }, + ), + }, +}) diff --git a/src/keystatic/blocks/general/xref.ts b/src/keystatic/blocks/general/xref.ts new file mode 100644 index 0000000..ab44cf9 --- /dev/null +++ b/src/keystatic/blocks/general/xref.ts @@ -0,0 +1,23 @@ +import { fields } from '@keystatic/core' +import { mark } from '@keystatic/core/content-components' + +import { linkIcon } from '@keystar/ui/icon/icons/linkIcon' + +import { ARTICLES } from '@/lib/constants/collections' + +export const xref = (collection: string = ARTICLES) => + mark({ + label: 'Internal link', + icon: linkIcon, + tag: 'a', + schema: { + reference: fields.relationship({ + label: 'Reference', + collection, + validation: { isRequired: true }, + }), + anchor: fields.text({ + label: 'Heading id', + }), + }, + }) diff --git a/src/keystatic/collections/general/articles.ts b/src/keystatic/collections/general/articles.ts new file mode 100644 index 0000000..5670ec6 --- /dev/null +++ b/src/keystatic/collections/general/articles.ts @@ -0,0 +1,30 @@ +import { collection, fields } from '@keystatic/core' + +import { base, published, stage } from '@/keystatic/fields/content/base' +import { content } from '@/keystatic/fields/content/content' +import { articleRoute } from '@/keystatic/fields/content/route' +import { articleSummary } from '@/keystatic/fields/content/summary' +import { ARTICLES, TAGS } from '@/lib/constants/collections' + +export const articles = collection({ + label: 'Articles', + slugField: 'title', + path: `src/content/general/${ARTICLES}/*`, + format: { + contentField: 'content', + }, + entryLayout: 'content', + columns: ['title', 'status', 'stage'], + schema: { + ...base, + stage, + route: articleRoute, + summary: articleSummary(ARTICLES), + published, + tags: fields.multiRelationship({ + label: 'Tags', + collection: TAGS, + }), + content: content(ARTICLES), + }, +}) diff --git a/src/keystatic/collections/general/index.ts b/src/keystatic/collections/general/index.ts new file mode 100644 index 0000000..0a11522 --- /dev/null +++ b/src/keystatic/collections/general/index.ts @@ -0,0 +1,11 @@ +import { ARTICLES, PAGES, TAGS } from '@/lib/constants/collections' + +import { articles } from './articles' +import { pages } from './pages' +import { tags } from './tags' + +export const contentCollections = { + [ARTICLES]: articles, + [PAGES]: pages, + [TAGS]: tags, +} diff --git a/src/keystatic/collections/general/pages.ts b/src/keystatic/collections/general/pages.ts new file mode 100644 index 0000000..9c7810d --- /dev/null +++ b/src/keystatic/collections/general/pages.ts @@ -0,0 +1,23 @@ +import { collection } from '@keystatic/core' + +import { base } from '@/keystatic/fields/content/base' +import { content } from '@/keystatic/fields/content/content' +import { pageRoute } from '@/keystatic/fields/content/route' +import { pageSummary } from '@/keystatic/fields/content/summary' +import { PAGES } from '@/lib/constants/collections' + +export const pages = collection({ + label: 'Pages', + slugField: 'title', + path: `src/content/general/${PAGES}/*`, + format: { + contentField: 'content', + }, + columns: ['title', 'status'], + schema: { + ...base, + route: pageRoute, + summary: pageSummary, + content: content(PAGES), + }, +}) diff --git a/src/keystatic/collections/general/tags.ts b/src/keystatic/collections/general/tags.ts new file mode 100644 index 0000000..a4eb451 --- /dev/null +++ b/src/keystatic/collections/general/tags.ts @@ -0,0 +1,21 @@ +import { collection, fields } from '@keystatic/core' + +import { title } from '@/keystatic/fields/content/base' +import { TAGS } from '@/lib/constants/collections' + +export const tags = collection({ + label: 'Tags', + slugField: 'title', + path: `src/content/general/${TAGS}/*`, + format: { + data: 'json', + }, + columns: ['title'], + schema: { + title, + description: fields.text({ + label: 'Description', + multiline: true, + }), + }, +}) diff --git a/src/keystatic/collections/site/index.ts b/src/keystatic/collections/site/index.ts new file mode 100644 index 0000000..49a9119 --- /dev/null +++ b/src/keystatic/collections/site/index.ts @@ -0,0 +1,7 @@ +import { RELEASES } from '@/lib/constants/collections' + +import { releases } from './releases' + +export const siteCollections = { + [RELEASES]: releases, +} diff --git a/src/keystatic/collections/site/releases.ts b/src/keystatic/collections/site/releases.ts new file mode 100644 index 0000000..5a86f54 --- /dev/null +++ b/src/keystatic/collections/site/releases.ts @@ -0,0 +1,66 @@ +import { collection, fields } from '@keystatic/core' + +import { CHANGE_TYPES } from '@/lib/constants/changelog' +import { ARTICLES, RELEASES } from '@/lib/constants/collections' + +const VERSION = /^\d+\.\d+(\.\d+)?$/ + +export const releases = collection({ + label: 'Releases', + slugField: 'version', + path: `src/content/site/${RELEASES}/*`, + columns: ['version', 'date'], + schema: { + version: fields.slug({ + name: { + label: 'Version', + description: 'Without the v: 0.12, 1.0, 1.0.3', + validation: { + isRequired: true, + pattern: { + regex: VERSION, + message: 'Numbers separated by dots, like 0.12 or 1.0.3', + }, + }, + }, + slug: { + label: 'File ID', + generate: (version) => `v${version.replaceAll('.', '-')}`, + }, + }), + date: fields.datetime({ + label: 'Date', + defaultValue: { + kind: 'now', + }, + validation: { + isRequired: true, + }, + }), + changes: fields.array( + fields.object({ + kind: fields.select({ + label: 'Kind', + options: CHANGE_TYPES, + defaultValue: 'added', + }), + article: fields.relationship({ + label: 'Article', + description: 'Empty for site-wide changes', + collection: ARTICLES, + }), + text: fields.text({ + label: 'Text', + validation: { + isRequired: true, + }, + }), + }), + { + label: 'Changes', + itemLabel: (props) => + `${props.fields.kind.value} · ${props.fields.article.value ?? 'site'} · ${props.fields.text.value}`, + }, + ), + }, +}) diff --git a/src/keystatic/fields/content/base.ts b/src/keystatic/fields/content/base.ts new file mode 100644 index 0000000..995c3d1 --- /dev/null +++ b/src/keystatic/fields/content/base.ts @@ -0,0 +1,60 @@ +import { fields } from '@keystatic/core' + +import { STAGE_OPTIONS } from '@/lib/constants/stage' +import { STATUS_OPTIONS } from '@/lib/constants/status' +import { SEGMENT, SEGMENT_MESSAGE, slugify } from '@/lib/content/slug' + +export const title = fields.slug({ + name: { + label: 'Title', + validation: { + isRequired: true, + }, + }, + slug: { + label: 'File ID', + description: 'Internal and unique across the collection.', + generate: slugify, + validation: { + pattern: { + regex: SEGMENT, + message: SEGMENT_MESSAGE, + }, + }, + }, +}) + +export const subtitle = fields.text({ + label: 'Subtitle', +}) + +export const status = fields.select({ + label: 'Status', + options: STATUS_OPTIONS, + defaultValue: 'draft', +}) + +export const stage = fields.select({ + label: 'Stage', + description: 'How finished the text is. Separate from status, which controls visibility', + options: STAGE_OPTIONS, + defaultValue: 'stub', +}) + +export const noindex = fields.checkbox({ + label: 'Hide from search engines', +}) + +export const published = fields.datetime({ + label: 'Published', + defaultValue: { + kind: 'now', + }, +}) + +export const base = { + title, + subtitle, + status, + noindex, +} diff --git a/src/keystatic/fields/content/content.ts b/src/keystatic/fields/content/content.ts new file mode 100644 index 0000000..c4a843f --- /dev/null +++ b/src/keystatic/fields/content/content.ts @@ -0,0 +1,14 @@ +import { fields } from '@keystatic/core' + +import { generalBlocks } from '@/keystatic/blocks/general' +import { assets } from '@/lib/images/storage' + +export const content = (collection: string) => + fields.markdoc({ + label: 'Content', + options: { + heading: [2, 3, 4], + image: assets(collection), + }, + components: generalBlocks(collection), + }) diff --git a/src/keystatic/fields/content/cover.ts b/src/keystatic/fields/content/cover.ts new file mode 100644 index 0000000..f7fec0f --- /dev/null +++ b/src/keystatic/fields/content/cover.ts @@ -0,0 +1,6 @@ +import { fields } from '@keystatic/core' + +import { imageFields } from './image' + +export const cover = (collection: string) => + fields.object(imageFields(collection), { label: 'Cover' }) diff --git a/src/keystatic/fields/content/image.ts b/src/keystatic/fields/content/image.ts new file mode 100644 index 0000000..ccc5c4a --- /dev/null +++ b/src/keystatic/fields/content/image.ts @@ -0,0 +1,36 @@ +import { fields } from '@keystatic/core' + +import { ASPECT_RATIOS, IMAGE_EFFECTS } from '@/lib/constants/figure' +import { assets } from '@/lib/images/storage' + +export const imageFields = (collection: string, { required = false } = {}) => ({ + image: fields.image({ + label: 'Image', + ...assets(collection), + validation: { + isRequired: required, + }, + }), + alt: fields.text({ + label: 'Alt text', + validation: { + isRequired: required, + }, + }), + caption: fields.text({ + label: 'Caption', + }), + credit: fields.text({ + label: 'Credit', + }), + effect: fields.select({ + label: 'Effect', + options: IMAGE_EFFECTS, + defaultValue: 'none', + }), + ratio: fields.select({ + label: 'Crop', + options: ASPECT_RATIOS, + defaultValue: 'original', + }), +}) diff --git a/src/keystatic/fields/content/route.ts b/src/keystatic/fields/content/route.ts new file mode 100644 index 0000000..43bb0a0 --- /dev/null +++ b/src/keystatic/fields/content/route.ts @@ -0,0 +1,47 @@ +import { fields } from '@keystatic/core' + +import { ARTICLES } from '@/lib/constants/collections' +import { OPTIONAL_SEGMENT, SEGMENT_MESSAGE } from '@/lib/content/slug' + +const segment = fields.text({ + label: 'URL segment', + description: 'Optional- Leave empty to use the slugified title', + validation: { + pattern: { + regex: OPTIONAL_SEGMENT, + message: SEGMENT_MESSAGE, + }, + }, +}) + +export const pageRoute = fields.object( + { + segment, + }, + { + label: 'Route', + }, +) + +export const articleRoute = fields.object( + { + segment, + parent: fields.relationship({ + label: 'Parent', + description: 'Empty makes this a root', + collection: ARTICLES, + }), + index: fields.integer({ + label: 'Index', + description: 'Sorts among siblings. Empty sorts by date, newest first', + }), + sequence: fields.checkbox({ + label: 'Sequence', + description: + 'Children form a numbered series with previous/next navigation. Give each child an index', + }), + }, + { + label: 'Route', + }, +) diff --git a/src/keystatic/fields/content/summary.ts b/src/keystatic/fields/content/summary.ts new file mode 100644 index 0000000..8042686 --- /dev/null +++ b/src/keystatic/fields/content/summary.ts @@ -0,0 +1,33 @@ +import { fields } from '@keystatic/core' + +import { cover } from './cover' + +const excerpt = fields.text({ + label: 'Excerpt', + multiline: true, + validation: { + length: { + max: 160, + }, + }, +}) + +export const pageSummary = fields.object( + { + excerpt, + }, + { + label: 'Summary', + }, +) + +export const articleSummary = (collection: string) => + fields.object( + { + excerpt, + cover: cover(collection), + }, + { + label: 'Summary', + }, + ) diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 3536b6d..9d96066 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,14 +1,26 @@ --- import { Font } from 'astro:assets' +import type { Article } from '@/lib/site/types' + import '@/styles/global.css' +import SiteFooter from '@/components/site/SiteFooter.astro' +import SiteHeader from '@/components/site/SiteHeader.astro' +import { resolveImage } from '@/lib/images/resolve' + interface Props { title: string description?: string + image?: string + noindex?: boolean + article?: Article } -const { title, description } = Astro.props +const { title, description, image, noindex, article } = Astro.props +const canonical = new URL(Astro.url.pathname, Astro.site) +const preview = image ? resolveImage(image) : undefined +const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined --- <!doctype html> @@ -20,14 +32,30 @@ const { title, description } = Astro.props <title>{title} {description && } + {noindex && } + + + + + {description && } + {previewUrl && } + + + - + +
+ +
+ diff --git a/src/layouts/general/ArticleLayout.astro b/src/layouts/general/ArticleLayout.astro new file mode 100644 index 0000000..990a715 --- /dev/null +++ b/src/layouts/general/ArticleLayout.astro @@ -0,0 +1,104 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro' +import Node from '@/components/markdoc/Node.astro' +import ArticleFooter from '@/components/post/Footer/ArticleFooter.astro' +import ArticleHeader from '@/components/post/Header/ArticleHeader.astro' +import ToC from '@/components/post/ToC.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import type { Rendered } from '@/lib/markdoc/types' +import { metaOf } from '@/lib/site/meta' +import { sequenceOf } from '@/lib/site/navigation' +import type { Article } from '@/lib/site/types' + +type Props = { + article: Article + rendered: Rendered +} + +const { article, rendered } = Astro.props +const meta = metaOf(article) +const sequence = sequenceOf(article) +--- + + +
+ +
+
+ +
+
+ +
+ {Astro.slots.has('margin') && ( + + )} + {rendered.footnotes.length > 0 && ( +
+ +
+ )} +
+ +
+ +
+ + diff --git a/src/layouts/general/PageLayout.astro b/src/layouts/general/PageLayout.astro new file mode 100644 index 0000000..4bcd2ba --- /dev/null +++ b/src/layouts/general/PageLayout.astro @@ -0,0 +1,71 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro' +import Node from '@/components/markdoc/Node.astro' +import SimpleHeader from '@/components/post/Header/SimpleHeader.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import type { Rendered } from '@/lib/markdoc/types' +import { metaOf } from '@/lib/site/meta' +import type { Page } from '@/lib/site/types' + +type Props = { + page: Page + rendered: Rendered +} + +const { page, rendered } = Astro.props +const meta = metaOf(page) +--- + + + +
+ +
+
+ +
+ {rendered.footnotes.length > 0 && ( +
+ +
+ )} +
+
+
+ + diff --git a/src/layouts/general/TagLayout.astro b/src/layouts/general/TagLayout.astro new file mode 100644 index 0000000..ff122cc --- /dev/null +++ b/src/layouts/general/TagLayout.astro @@ -0,0 +1,22 @@ +--- +import DevBanner from '@/components/dev/DevBanner.astro' +import ArticleCardGrid from '@/components/post/ArticleCard/ArticleCardGrid.astro' +import SimpleHeader from '@/components/post/Header/SimpleHeader.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import type { Tag } from '@/lib/site/types' + +type Props = { tag: Tag } + +const { tag } = Astro.props +--- + + + + + + diff --git a/src/lib/constants/blocks/notes.ts b/src/lib/constants/blocks/notes.ts new file mode 100644 index 0000000..7390b7f --- /dev/null +++ b/src/lib/constants/blocks/notes.ts @@ -0,0 +1,16 @@ +export const NOTE_TYPES = [ + { + label: 'Note', + value: 'default', + }, + { + label: 'Example', + value: 'example', + }, + { + label: 'Optional', + value: 'optional', + }, +] as const + +export type CalloutType = (typeof NOTE_TYPES)[number]['value'] diff --git a/src/lib/constants/blocks/table.ts b/src/lib/constants/blocks/table.ts new file mode 100644 index 0000000..1cf6168 --- /dev/null +++ b/src/lib/constants/blocks/table.ts @@ -0,0 +1,37 @@ +export const TABLE_VARIANTS = [ + { + label: 'Default', + value: 'default', + }, + { + label: 'Matrix', + value: 'matrix', + }, +] as const + +export type TableVariant = (typeof TABLE_VARIANTS)[number]['value'] + +export const COLUMN_TYPES = [ + { + label: 'Default', + value: 'default', + }, + { + label: 'Number', + value: 'num', + }, + { + label: 'Key', + value: 'key', + }, + { + label: 'Sentence', + value: 'text', + }, + { + label: 'Row header', + value: 'rowheader', + }, +] as const + +export type ColumnType = (typeof COLUMN_TYPES)[number]['value'] diff --git a/src/lib/constants/changelog.ts b/src/lib/constants/changelog.ts new file mode 100644 index 0000000..4eb5990 --- /dev/null +++ b/src/lib/constants/changelog.ts @@ -0,0 +1,20 @@ +export const CHANGE_TYPES = [ + { + label: 'Added', + value: 'added', + }, + { + label: 'Changed', + value: 'changed', + }, + { + label: 'Fixed', + value: 'fixed', + }, + { + label: 'Removed', + value: 'removed', + }, +] as const + +export type ChangeType = (typeof CHANGE_TYPES)[number]['value'] diff --git a/src/lib/constants/collections.ts b/src/lib/constants/collections.ts new file mode 100644 index 0000000..a87064c --- /dev/null +++ b/src/lib/constants/collections.ts @@ -0,0 +1,14 @@ +/* === CONTENT COLLECTIONS === */ + +export const ARTICLES = 'articles' +export const PAGES = 'pages' +export const TAGS = 'tags' + +export const COLLECTION_LABELS = { + [ARTICLES]: 'Article', + [PAGES]: 'Page', + [TAGS]: 'Tag', +} as const + +/* === SITE COLLECTIONS === */ +export const RELEASES = 'releases' diff --git a/src/lib/constants/figure.ts b/src/lib/constants/figure.ts new file mode 100644 index 0000000..53ff2b1 --- /dev/null +++ b/src/lib/constants/figure.ts @@ -0,0 +1,23 @@ +export const FIGURE_SIZES = [ + { label: 'Inline', value: 'inline' }, + { label: 'Wide', value: 'wide' }, +] as const + +export const IMAGE_EFFECTS = [ + { label: 'None', value: 'none' }, + { label: 'Xeroxed', value: 'xeroxed' }, +] as const + +export const ASPECT_RATIOS = [ + { label: 'Original', value: 'original' }, + { label: 'Panorama (21:9)', value: '21-9' }, + { label: 'Wide (16:9)', value: '16-9' }, + { label: 'Photo (3:2)', value: '3-2' }, + { label: 'Classic (4:3)', value: '4-3' }, + { label: 'Square (1:1)', value: '1-1' }, + { label: 'Portrait (4:5)', value: '4-5' }, +] as const + +export type FigureSize = (typeof FIGURE_SIZES)[number]['value'] +export type ImageEffect = (typeof IMAGE_EFFECTS)[number]['value'] +export type AspectRatio = (typeof ASPECT_RATIOS)[number]['value'] diff --git a/src/lib/constants/site.ts b/src/lib/constants/site.ts new file mode 100644 index 0000000..abf11c2 --- /dev/null +++ b/src/lib/constants/site.ts @@ -0,0 +1,4 @@ +export const SITE = { + title: 'dave-dmg', + tagline: 'Essays, frameworks and game tools', +} as const diff --git a/src/lib/constants/stage.ts b/src/lib/constants/stage.ts new file mode 100644 index 0000000..d291eec --- /dev/null +++ b/src/lib/constants/stage.ts @@ -0,0 +1,20 @@ +export const STAGE_OPTIONS = [ + { + label: 'Stub', + value: 'stub', + }, + { + label: 'Developing', + value: 'developing', + }, + { + label: 'Complete', + value: 'complete', + }, + { + label: 'Ongoing', + value: 'ongoing', + }, +] as const + +export type Stage = (typeof STAGE_OPTIONS)[number]['value'] diff --git a/src/lib/constants/status.ts b/src/lib/constants/status.ts new file mode 100644 index 0000000..c9355f3 --- /dev/null +++ b/src/lib/constants/status.ts @@ -0,0 +1,16 @@ +export const STATUS_OPTIONS = [ + { + label: 'Draft', + value: 'draft', + }, + { + label: 'Published', + value: 'published', + }, + { + label: 'Unlisted', + value: 'unlisted', + }, +] as const + +export type Status = (typeof STATUS_OPTIONS)[number]['value'] diff --git a/src/lib/constants/terms.ts b/src/lib/constants/terms.ts new file mode 100644 index 0000000..61c951f --- /dev/null +++ b/src/lib/constants/terms.ts @@ -0,0 +1,11 @@ +export const TERM_KINDS = [ + { label: 'Entry', value: 'entry' }, + { label: 'Prime', value: 'prime' }, + { label: 'Essence', value: 'essence' }, + { label: 'Element', value: 'element' }, + { label: 'Stage', value: 'stage' }, + { label: 'Tier', value: 'tier' }, + { label: 'Premise', value: 'premise' }, +] as const + +export type TermKind = (typeof TERM_KINDS)[number]['value'] diff --git a/src/lib/content/dates.ts b/src/lib/content/dates.ts new file mode 100644 index 0000000..6514407 --- /dev/null +++ b/src/lib/content/dates.ts @@ -0,0 +1,42 @@ +const DISPLAY = new Intl.DateTimeFormat('en-GB', { + timeZone: 'UTC', + day: 'numeric', + month: 'long', + year: 'numeric', +}) + +const MONTHS = [ + 'JAN', + 'FEB', + 'MAR', + 'APR', + 'MAY', + 'JUN', + 'JUL', + 'AUG', + 'SEP', + 'OCT', + 'NOV', + 'DEC', +] as const + +export function parseDate(value: string | null): Date | null { + if (!value) return null + const date = new Date(/(z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`) + return Number.isNaN(date.getTime()) ? null : date +} + +export const formatDate = (date: Date): string => DISPLAY.format(date) + +export const isoDate = (date: Date): string => date.toISOString().slice(0, 10) + +export const toMilitaryDTG = (datestring: Date, timeZone = 'A'): string => { + const d = new Date(datestring) + const day = d.getUTCDate().toString().padStart(2, '0') + const hours = d.getUTCHours().toString().padStart(2, '0') + const minutes = d.getUTCMinutes().toString().padStart(2, '0') + const month = MONTHS[d.getUTCMonth()] + const year = d.getUTCFullYear().toString().slice(-2) + + return `${day}${hours}${minutes}${timeZone}${month}${year}` +} diff --git a/src/lib/content/load.ts b/src/lib/content/load.ts new file mode 100644 index 0000000..b83abc2 --- /dev/null +++ b/src/lib/content/load.ts @@ -0,0 +1,49 @@ +import { ARTICLES, PAGES, RELEASES, TAGS } from '../constants/collections' +import { createDiagnostics } from '../diagnostics' +import type { Diagnostics } from '../diagnostics/types' +import { createContentReader } from './reader' +import type { ArticleEntry, Content, PageEntry, ReleaseEntry, TagEntry } from './types' + +interface CollectionReader { + list(): Promise + read(id: string): Promise +} + +function reason(error: unknown): string { + const [first, ...rest] = (error instanceof Error ? error.message : String(error)).split('\n') + return first.endsWith(':') && rest.length ? rest.join('; ') : first +} + +async function readAll( + reader: CollectionReader, + collection: string, + problems: Diagnostics, +): Promise> { + const ids = (await reader.list()).sort() + const entries = await Promise.all( + ids.map(async (id) => { + try { + return [id, await reader.read(id)] as const + } catch (error) { + problems.error(collection, id, reason(error)) + return [id, null] as const + } + }), + ) + + const map = new Map() + for (const [id, entry] of entries) if (entry) map.set(id, entry) + return map +} + +export async function loadContent(): Promise { + const { collections } = createContentReader() + const problems = createDiagnostics() + const [articles, pages, tags, releases] = await Promise.all([ + readAll(collections.articles, ARTICLES, problems), + readAll(collections.pages, PAGES, problems), + readAll(collections.tags, TAGS, problems), + readAll(collections.releases, RELEASES, problems), + ]) + return { articles, pages, tags, releases, diagnostics: problems.list } +} diff --git a/src/lib/content/numerals.ts b/src/lib/content/numerals.ts new file mode 100644 index 0000000..c2723e1 --- /dev/null +++ b/src/lib/content/numerals.ts @@ -0,0 +1,27 @@ +const NUMERALS = [ + [1000, 'M'], + [900, 'CM'], + [500, 'D'], + [400, 'CD'], + [100, 'C'], + [90, 'XC'], + [50, 'L'], + [40, 'XL'], + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], +] as const + +export function toRoman(value: number): string { + let rest = value + let result = '' + for (const [amount, numeral] of NUMERALS) { + while (rest >= amount) { + result += numeral + rest -= amount + } + } + return result +} diff --git a/src/lib/content/reader.ts b/src/lib/content/reader.ts new file mode 100644 index 0000000..dc4cdd4 --- /dev/null +++ b/src/lib/content/reader.ts @@ -0,0 +1,5 @@ +import { createReader } from '@keystatic/core/reader' + +import config from '~/keystatic.config' + +export const createContentReader = () => createReader('.', config) diff --git a/src/lib/content/slug.ts b/src/lib/content/slug.ts new file mode 100644 index 0000000..4bf4c91 --- /dev/null +++ b/src/lib/content/slug.ts @@ -0,0 +1,34 @@ +export const SEGMENT = /^[a-z0-9][a-z0-9_-]*$/ +export const OPTIONAL_SEGMENT = /^([a-z0-9][a-z0-9_-]*)?$/ +export const SEGMENT_MESSAGE = 'Lowercase letters, digits, - and _' + +const UMLAUTS: Record = { + ä: 'ae', + ö: 'oe', + ü: 'ue', + ß: 'ss', + Ä: 'ae', + Ö: 'oe', + Ü: 'ue', + å: 'aa', + æ: 'ae', + ø: 'oe', + Å: 'aa', + Æ: 'ae', + Ø: 'oe', +} + +export const slugify = (text: string): string => { + return text + .split('') + .map((char) => UMLAUTS[char] || char) + .join('') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9 _-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^[-_]+|[-_]+$/g, '') +} diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts new file mode 100644 index 0000000..b1d8e0c --- /dev/null +++ b/src/lib/content/types.ts @@ -0,0 +1,20 @@ +import type { Entry } from '@keystatic/core/reader' + +import type config from '~/keystatic.config' + +import type { Diagnostic } from '../diagnostics/types' + +type Collections = typeof config.collections + +export type ArticleEntry = Entry +export type PageEntry = Entry +export type TagEntry = Entry +export type ReleaseEntry = Entry + +export type Content = { + articles: Map + pages: Map + tags: Map + releases: Map + diagnostics: Diagnostic[] +} diff --git a/src/lib/diagnostics/index.ts b/src/lib/diagnostics/index.ts new file mode 100644 index 0000000..7a11c53 --- /dev/null +++ b/src/lib/diagnostics/index.ts @@ -0,0 +1,22 @@ +import type { Diagnostic, Diagnostics } from './types' + +export function createDiagnostics(): Diagnostics { + const list: Diagnostic[] = [] + return { + list, + error: (collection, id, message) => + list.push({ + level: 'error', + collection, + id, + message, + }), + warning: (collection, id, message) => + list.push({ + level: 'warning', + collection, + id, + message, + }), + } +} diff --git a/src/lib/diagnostics/report.ts b/src/lib/diagnostics/report.ts new file mode 100644 index 0000000..0bdefd8 --- /dev/null +++ b/src/lib/diagnostics/report.ts @@ -0,0 +1,26 @@ +import type { Diagnostic } from './types' + +const SYMBOL = { + error: '✖', + warning: '▲', +} as const + +const byLevelThenEntry = (a: Diagnostic, b: Diagnostic) => + (a.level === b.level ? 0 : a.level === 'error' ? -1 : 1) || + a.collection.localeCompare(b.collection) || + (a.id ?? '').localeCompare(b.id ?? '') + +export function formatDiagnostics(list: Diagnostic[]): string { + return [...list] + .sort(byLevelThenEntry) + .map((d) => `${SYMBOL[d.level]} ${d.collection}/${d.id ?? '*'} ${d.message}`) + .join('\n') +} + +export function assertNoErrors(list: Diagnostic[]): void { + const errors = list.filter((d) => d.level === 'error').length + if (errors) throw new Error(`Content has ${errors} error(s):\n${formatDiagnostics(list)}`) +} + +export const forEntry = (list: Diagnostic[], collection: string, id: string) => + list.filter((d) => d.collection === collection && d.id === id) diff --git a/src/lib/diagnostics/types.ts b/src/lib/diagnostics/types.ts new file mode 100644 index 0000000..597de5f --- /dev/null +++ b/src/lib/diagnostics/types.ts @@ -0,0 +1,14 @@ +export type Level = 'error' | 'warning' + +export type Diagnostic = { + level: Level + collection: string + id: string | null + message: string +} + +export type Diagnostics = { + list: Diagnostic[] + error(collection: string, id: string | null, message: string): void + warning(collection: string, id: string | null, message: string): void +} diff --git a/src/lib/finder/element.ts b/src/lib/finder/element.ts new file mode 100644 index 0000000..16c974b --- /dev/null +++ b/src/lib/finder/element.ts @@ -0,0 +1,191 @@ +import { ARTICLES, COLLECTION_LABELS } from '@/lib/constants/collections' +import { prepare, search, type Prepared } from '@/lib/finder/search' +import type { DirectoryEntry } from '@/lib/site/types' + +const LIMIT = 30 +const INDEX_URL = '/api/site-index.json' + +const pick = (root: ParentNode, selector: string): T => { + const element = root.querySelector(selector) + if (!element) throw new Error(`Finder: missing ${selector}`) + return element +} + +const countOf = (total: number) => `${String(total).padStart(2, '0')} entries` + +class SiteFinder extends HTMLElement { + #dialog!: HTMLDialogElement + #input!: HTMLInputElement + #results!: HTMLElement + #context!: HTMLElement + #matches!: HTMLElement + #empty!: HTMLElement + #mode!: HTMLElement + #subject!: HTMLElement + #count!: HTMLElement + #row!: HTMLTemplateElement + + #index: Promise[]> | null = null + #active = -1 + #initial = { subject: '', count: '' } + + connectedCallback() { + this.#dialog = pick(this, 'dialog') + this.#input = pick(this, '[data-finder-input]') + this.#results = pick(this, '[role="listbox"]') + this.#context = pick(this, '[data-finder-context]') + this.#matches = pick(this, '[data-finder-matches]') + this.#empty = pick(this, '[data-finder-empty]') + this.#mode = pick(this, '[data-finder-mode]') + this.#subject = pick(this, '[data-finder-subject]') + this.#count = pick(this, '[data-finder-count]') + this.#row = pick(this, 'template[data-finder-row]') + this.#initial = { + subject: this.#subject.textContent ?? '', + count: this.#count.textContent ?? '', + } + + if (!/Mac|iPhone|iPad/.test(navigator.userAgent)) + for (const key of this.querySelectorAll('[data-finder-shortcut]')) + key.textContent = 'Ctrl K' + + document.addEventListener('keydown', this.#onShortcut) + this.#input.addEventListener('input', this.#update) + this.#input.addEventListener('keydown', this.#onKey) + this.#results.addEventListener('pointermove', this.#onPointer) + this.#dialog.addEventListener('click', this.#onBackdrop) + this.#dialog.addEventListener('close', this.#reset) + } + + disconnectedCallback() { + document.removeEventListener('keydown', this.#onShortcut) + } + + #onShortcut = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return + event.preventDefault() + if (this.#dialog.open) this.#dialog.close() + else this.#dialog.showModal() + } + + #onBackdrop = (event: MouseEvent) => { + if (event.target !== this.#dialog) return + const box = this.#dialog.getBoundingClientRect() + const inside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom + if (!inside) this.#dialog.close() + } + + #onKey = (event: KeyboardEvent) => { + const options = this.#options() + if (!options.length) return + + if (event.key === 'ArrowDown') { + event.preventDefault() + this.#select((this.#active + 1) % options.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + this.#select(this.#active <= 0 ? options.length - 1 : this.#active - 1) + } else if (event.key === 'Enter') { + const option = + options[this.#active] ?? (this.#input.value.trim() ? options[0] : undefined) + if (!option) return + event.preventDefault() + option.click() + } + } + + #onPointer = (event: PointerEvent) => { + const option = (event.target as Element).closest('[role="option"]') + if (!option) return + const index = this.#options().indexOf(option) + if (index !== this.#active) this.#select(index, false) + } + + #update = async () => { + const query = this.#input.value.trim() + this.#select(-1) + + if (!query) { + this.#show(false) + this.#mode.textContent = 'Context' + this.#subject.textContent = this.#initial.subject + this.#count.textContent = this.#initial.count + return + } + + const items = await this.#load() + if (this.#input.value.trim() !== query) return + + const found = search(items, query, LIMIT) + this.#matches.replaceChildren(...found.map((entry, i) => this.#render(entry, i))) + this.#empty.hidden = found.length > 0 + this.#show(true) + this.#mode.textContent = 'Query' + this.#subject.textContent = query + this.#count.textContent = countOf(found.length) + } + + #reset = () => { + this.#input.value = '' + void this.#update() + } + + #load() { + this.#index ??= fetch(INDEX_URL) + .then((response) => { + if (!response.ok) throw new Error(response.statusText) + return response.json() as Promise + }) + .then((entries) => prepare(entries.filter((entry) => entry.collection === ARTICLES))) + .catch(() => { + this.#index = null + return [] + }) + return this.#index + } + + #show(searching: boolean) { + this.#context.hidden = searching + this.#matches.hidden = !searching + if (!searching) this.#empty.hidden = true + } + + #options(): HTMLAnchorElement[] { + const visible = this.#matches.hidden ? this.#context : this.#matches + return [...visible.querySelectorAll('[role="option"]')] + } + + #select(index: number, scroll = true) { + for (const option of this.#results.querySelectorAll('[aria-selected]')) + option.removeAttribute('aria-selected') + + this.#active = index + const option = this.#options()[index] + if (!option) { + this.#input.removeAttribute('aria-activedescendant') + return + } + + option.setAttribute('aria-selected', 'true') + this.#input.setAttribute('aria-activedescendant', option.id) + if (scroll) option.scrollIntoView({ block: 'nearest' }) + } + + #render(entry: DirectoryEntry, index: number) { + const row = pick(this.#row.content, 'a').cloneNode( + true, + ) as HTMLAnchorElement + row.id = `finder-match-${index}` + row.href = entry.path + pick(row, '[data-field="type"]').textContent = COLLECTION_LABELS[entry.collection] + pick(row, '[data-field="label"]').textContent = entry.title + pick(row, '[data-field="path"]').textContent = entry.path + return row + } +} + +if (!customElements.get('site-finder')) customElements.define('site-finder', SiteFinder) diff --git a/src/lib/finder/search.ts b/src/lib/finder/search.ts new file mode 100644 index 0000000..145b706 --- /dev/null +++ b/src/lib/finder/search.ts @@ -0,0 +1,61 @@ +import { slugify } from '@/lib/content/slug' + +type Searchable = { + title: string + path: string +} + +export type Prepared = { + entry: T + title: string + words: string[] + path: string +} + +const termsOf = (text: string) => slugify(text).split('-').filter(Boolean) + +export function prepare(entries: T[]): Prepared[] { + return entries.map((entry) => ({ + entry, + title: slugify(entry.title), + words: termsOf(entry.title), + path: entry.path.toLowerCase(), + })) +} + +function scoreTerm(item: Prepared, term: string): number { + if (item.words.includes(term)) return 80 + if (item.words.some((word) => word.startsWith(term))) return 60 + if (item.title.includes(term)) return 40 + if (item.path.includes(term)) return 10 + return 0 +} + +export function search( + items: Prepared[], + query: string, + limit: number, +): T[] { + const terms = termsOf(query) + if (!terms.length) return [] + const exact = terms.join('-') + + const scored: { entry: T; score: number }[] = [] + for (const item of items) { + let score = item.title === exact ? 100 : 0 + for (const term of terms) { + const points = scoreTerm(item, term) + if (!points) { + score = 0 + break + } + score += points + } + if (score) scored.push({ entry: item.entry, score }) + } + + return scored + .sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title)) + .slice(0, limit) + .map(({ entry }) => entry) +} diff --git a/src/lib/images/resolve.ts b/src/lib/images/resolve.ts new file mode 100644 index 0000000..5fb5ffe --- /dev/null +++ b/src/lib/images/resolve.ts @@ -0,0 +1,8 @@ +import type { ImageMetadata } from 'astro' + +const images = import.meta.glob( + '/src/assets/content/**/*.{png,jpg,jpeg,webp,avif,gif,svg,PNG,JPG,JPEG,WEBP,AVIF,GIF,SVG}', + { eager: true, import: 'default' }, +) + +export const resolveImage = (src: string): ImageMetadata | undefined => images[decodeURI(src)] diff --git a/src/lib/images/storage.ts b/src/lib/images/storage.ts new file mode 100644 index 0000000..5568d52 --- /dev/null +++ b/src/lib/images/storage.ts @@ -0,0 +1,15 @@ +import { slugify } from '../content/slug' + +export const ASSET_ROOT = 'src/assets/content' + +export function cleanFilename(filename: string): string { + const dot = filename.lastIndexOf('.') + if (dot <= 0) return slugify(filename) + return `${slugify(filename.slice(0, dot)) || 'image'}${filename.slice(dot).toLowerCase()}` +} + +export const assets = (collection: string) => ({ + directory: `${ASSET_ROOT}/${collection}`, + publicPath: `/${ASSET_ROOT}/${collection}/`, + transformFilename: cleanFilename, +}) diff --git a/src/lib/markdoc/highlight.ts b/src/lib/markdoc/highlight.ts new file mode 100644 index 0000000..351931f --- /dev/null +++ b/src/lib/markdoc/highlight.ts @@ -0,0 +1,38 @@ +import { createHighlighter, type Highlighter } from 'shiki' + +const THEMES = { light: 'github-light', dark: 'github-dark' } as const + +const LANGS = [ + 'astro', + 'bash', + 'css', + 'diff', + 'html', + 'ini', + 'js', + 'json', + 'jsonc', + 'jsx', + 'md', + 'python', + 'sql', + 'ts', + 'tsx', + 'xml', + 'yaml', +] + +export type Highlight = (code: string, lang: string) => string | null + +let ready: Promise | null = null + +export async function createHighlight(): Promise { + ready ??= createHighlighter({ themes: Object.values(THEMES), langs: LANGS }) + const highlighter = await ready + const loaded = new Set(highlighter.getLoadedLanguages()) + + return (code, lang) => + loaded.has(lang) + ? highlighter.codeToHtml(code, { lang, themes: THEMES, defaultColor: false }) + : null +} diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts new file mode 100644 index 0000000..3ac0880 --- /dev/null +++ b/src/lib/markdoc/index.ts @@ -0,0 +1,49 @@ +import Markdoc from '@markdoc/markdoc' + +import { createDiagnostics } from '../diagnostics' +import { createHighlight } from './highlight' +import { createNodes } from './nodes' +import { createTags } from './tags' +import type { NodeState, Renderable, Rendered, ResolveLink } from './types' + +const LEVEL = { + critical: 'error', + error: 'error', + warning: 'warning', +} as const + +export async function render( + { collection, id, content }: Renderable, + resolveLink: ResolveLink, +): Promise { + const { node } = await content() + const state: NodeState = { + headings: [], + ids: new Set(), + footnotes: [], + sidenoteCount: 0, + spoilerCount: 0, + markers: new Set(), + problems: [], + } + const highlight = await createHighlight() + const config = { + nodes: createNodes(state, highlight), + tags: createTags(state, resolveLink), + } + const problems = createDiagnostics() + + for (const { lines, error } of Markdoc.validate(node, config)) { + const level = LEVEL[error.level as keyof typeof LEVEL] + if (level) problems[level](collection, id, `Content line ${lines[0] + 1}: ${error.message}`) + } + const tree = Markdoc.transform(node, config) + for (const { level, message } of state.problems) problems[level](collection, id, message) + + return { + tree, + headings: state.headings, + footnotes: state.footnotes, + diagnostics: problems.list, + } +} diff --git a/src/lib/markdoc/nodes.ts b/src/lib/markdoc/nodes.ts new file mode 100644 index 0000000..2fff927 --- /dev/null +++ b/src/lib/markdoc/nodes.ts @@ -0,0 +1,83 @@ +import Markdoc, { type Node, type Schema } from '@markdoc/markdoc' + +import { slugify } from '../content/slug' +import type { Highlight } from './highlight' +import type { NodeState } from './types' + +const { Tag } = Markdoc + +const textOf = (node: Node): string => + node.type === 'text' || node.type === 'code' + ? String(node.attributes.content ?? '') + : node.children.map(textOf).join('') + +function uniqueId(text: string, ids: Set): string { + const base = slugify(text) || 'section' + let id = base + for (let n = 2; ids.has(id); n++) id = `${base}-${n}` + ids.add(id) + return id +} + +export function createNodes(state: NodeState, highlight: Highlight): Record { + return { + fence: { + ...Markdoc.nodes.fence, + transform(node) { + /* transformAttributes renames `language` to `data-language`, so the + raw attribute is the honest source. The content carries a trailing + newline that would render as a blank last line. */ + const lang = String(node.attributes.language ?? '').trim() + const code = String(node.attributes.content ?? '').replace(/\n$/, '') + const html = lang ? highlight(code, lang) : null + + if (lang && !html) + state.problems.push({ + level: 'warning', + message: `No highlighting for language "${lang}" — add it to LANGS in highlight.ts.`, + }) + + return new Tag('Code', { code, lang: lang || undefined, html: html ?? undefined }, []) + }, + }, + document: { + ...Markdoc.nodes.document, + transform: (node, config) => node.transformChildren(config), + }, + heading: { + ...Markdoc.nodes.heading, + transform(node, config) { + const level = Number(node.attributes.level) + const text = textOf(node) + const id = uniqueId(text, state.ids) + state.headings.push({ id, level, text }) + return new Tag(`h${level}`, { id }, node.transformChildren(config)) + }, + }, + table: { + ...Markdoc.nodes.table, + transform(node, config) { + const table = new Tag( + 'table', + node.transformAttributes(config), + node.transformChildren(config), + ) + return new Tag('div', { class: 'table-scroll', tabindex: 0 }, [table]) + }, + }, + image: { + ...Markdoc.nodes.image, + render: 'Image', + validate: (node) => + node.attributes.alt + ? [] + : [ + { + id: 'image-alt', + level: 'warning', + message: 'An image has no alt text', + }, + ], + }, + } +} diff --git a/src/lib/markdoc/notes.ts b/src/lib/markdoc/notes.ts new file mode 100644 index 0000000..3533731 --- /dev/null +++ b/src/lib/markdoc/notes.ts @@ -0,0 +1,17 @@ +import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc' + +const unwrap: Schema = { transform: (node, config) => node.transformChildren(config) } + +const INLINE = { + nodes: { + ...Markdoc.nodes, + document: unwrap, + paragraph: unwrap, + }, + tags: {}, +} + +export function inlineOf(text: string): RenderableTreeNode[] { + const rendered = Markdoc.transform(Markdoc.parse(text), INLINE) + return Array.isArray(rendered) ? rendered : [rendered] +} diff --git a/src/lib/markdoc/tags.ts b/src/lib/markdoc/tags.ts new file mode 100644 index 0000000..90e260e --- /dev/null +++ b/src/lib/markdoc/tags.ts @@ -0,0 +1,190 @@ +import { fields } from '@keystatic/core' + +import Markdoc, { type Node, type RenderableTreeNode, type Schema } from '@markdoc/markdoc' + +import { generalBlocks } from '@/keystatic/blocks/general' + +import { ARTICLES } from '../constants/collections' +import { inlineOf } from './notes' +import { eachTag, firstTag, isTag } from './tree' +import type { NodeState, ResolveLink } from './types' + +const { Tag } = Markdoc + +function capitalise(children: RenderableTreeNode[]): void { + const paragraph = firstTag(children, 'p') + if (!paragraph) return + + const [first, ...rest] = paragraph.children + if (typeof first !== 'string' || !first) return + + paragraph.children = [ + new Tag('span', { class: 'lead__cap' }, [first.slice(0, 1)]), + first.slice(1), + ...rest, + ] +} + +function applyColumns( + children: RenderableTreeNode[], + columns: string[], + state: NodeState, +): void { + let widest = 0 + eachTag(children, 'tr', (row) => { + widest = Math.max(widest, row.children.length) + + row.children.forEach((cell, index) => { + const kind = columns[index] + if (!isTag(cell) || !kind || kind === 'default') return + if (kind === 'rowheader') { + if (cell.name !== 'td') return + cell.name = 'th' + cell.attributes.scope = 'row' + return + } + + cell.attributes.class = [cell.attributes.class, kind].filter(Boolean).join(' ') + }) + }) + + if (widest && widest !== columns.length) + state.problems.push({ + level: 'warning', + message: `A table wrapper lists ${columns.length} column types for a table ${widest} columns wide.`, + }) +} + +const generated = + fields.markdoc.createMarkdocConfig({ + components: generalBlocks(ARTICLES), + render: { + tags: { + definitions: 'DefinitionList', + definition: 'Definition', + callout: 'Callout', + figure: 'Figure', + tableWrapper: 'TableWrapper', + quote: 'Quote', + reveal: 'Reveal', + }, + }, + }).tags ?? {} + +const text = (value: unknown) => String(value ?? '') + +export function createTags(state: NodeState, resolveLink: ResolveLink): Record { + return { + ...generated, + footnote: { + ...generated.footnote, + transform(node) { + const n = state.footnotes.length + 1 + state.footnotes.push({ n, children: inlineOf(text(node.attributes.note)) }) + return new Tag('sup', { class: 'footnote-ref' }, [ + new Tag('a', { href: `#fn-${n}`, id: `fnref-${n}` }, [String(n)]), + ]) + }, + }, + spoiler: { + ...generated.spoiler, + transform(node, config) { + const id = `spoiler-${(state.spoilerCount += 1)}` + return new Tag('span', { class: 'spoiler' }, [ + new Tag('input', { id, class: 'spoiler__toggle', type: 'checkbox' }, []), + new Tag('label', { class: 'spoiler__text', for: id }, node.transformChildren(config)), + ]) + }, + }, + sidenote: { + ...generated.sidenote, + transform(node, config) { + const { marker, type } = node.transformAttributes(config) as Record + const noteType = type || 'default' + if (state.markers.has(marker)) + state.problems.push({ + level: 'warning', + message: `Two sidenotes use the marker "${marker}".`, + }) + state.markers.add(marker) + const n = (state.sidenoteCount += 1) + const children = inlineOf(text(node.attributes.note)) + return [ + new Tag('a', { class: 'sidenote-ref', id: `snref-${n}`, href: `#sn-${n}` }, [marker]), + new Tag( + 'span', + { class: `sidenote sidenote--${noteType}`, id: `sn-${n}`, role: 'note' }, + [new Tag('span', { class: 'sidenote__marker' }, [marker]), ...children], + ), + ] + }, + }, + xref: { + ...generated.xref, + transform(node: Node, config) { + const id = text(node.attributes.reference) + const anchor = text(node.attributes.anchor) + const children = node.transformChildren(config) + const target = resolveLink(id) + + if (!target) { + state.problems.push({ level: 'error', message: `Link to "${id}": no such article.` }) + return children + } + if (!target.path) { + state.problems.push({ + level: 'warning', + message: `Link to "${id}": that article has no page yet, so the link is plain text.`, + }) + return children + } + return new Tag( + 'a', + { href: anchor ? `${target.path}#${anchor}` : target.path }, + children, + ) + }, + }, + tableWrapper: { + ...generated.tableWrapper, + /* Keystatic makes an array attribute required; a table without column + types is the normal case. */ + attributes: { + ...generated.tableWrapper?.attributes, + columns: { ...generated.tableWrapper?.attributes?.columns, required: false }, + }, + transform(node, config) { + const { columns, ...attributes } = node.transformAttributes(config) as { + columns?: string[] + } + const children = node.transformChildren(config) + if (columns?.length) applyColumns(children, columns, state) + return new Tag('TableWrapper', attributes, children) + }, + /* The children of a Keystatic wrapper are free-form block content, so + "exactly one table" can only be a build-time check. */ + validate(node) { + const tables = node.children.filter( + (child) => child.type === 'table' || child.tag === 'table', + ).length + return tables === 1 + ? [] + : [ + { + id: 'table-wrapper-children', + level: 'error' as const, + message: `A table wrapper holds exactly one table, this one holds ${tables}.`, + }, + ] + }, + }, + lead: { + ...generated.lead, + transform(node, config) { + const children = node.transformChildren(config) + capitalise(children) + return new Tag('Lead', node.transformAttributes(config), children) + }, + }, + } +} diff --git a/src/lib/markdoc/tree.ts b/src/lib/markdoc/tree.ts new file mode 100644 index 0000000..68cf188 --- /dev/null +++ b/src/lib/markdoc/tree.ts @@ -0,0 +1,25 @@ +import Markdoc, { type RenderableTreeNode, type Tag } from '@markdoc/markdoc' + +export const isTag = (node: RenderableTreeNode): node is Tag => Markdoc.Tag.isTag(node) + +export function firstTag(nodes: RenderableTreeNode[], name: string): Tag | null { + for (const node of nodes) { + if (!isTag(node)) continue + if (node.name === name) return node + const found = firstTag(node.children, name) + if (found) return found + } + return null +} + +export function eachTag( + nodes: RenderableTreeNode[], + name: string, + visit: (tag: Tag) => void, +): void { + for (const node of nodes) { + if (!isTag(node)) continue + if (node.name === name) visit(node) + else eachTag(node.children, name, visit) + } +} diff --git a/src/lib/markdoc/types.ts b/src/lib/markdoc/types.ts new file mode 100644 index 0000000..631aad0 --- /dev/null +++ b/src/lib/markdoc/types.ts @@ -0,0 +1,46 @@ +import type { Node, RenderableTreeNode } from '@markdoc/markdoc' + +import type { Diagnostic, Level } from '../diagnostics/types' + +export type Heading = { + id: string + level: number + text: string +} + +export type Footnote = { + n: number + children: RenderableTreeNode[] +} + +export type Problem = { + level: Level + message: string +} + +export type NodeState = { + headings: Heading[] + ids: Set + footnotes: Footnote[] + sidenoteCount: number + spoilerCount: number + markers: Set + problems: Problem[] +} + +export type LinkTarget = { path: string | null } | undefined + +export type ResolveLink = (id: string) => LinkTarget + +export type Rendered = { + tree: RenderableTreeNode + headings: Heading[] + footnotes: Footnote[] + diagnostics: Diagnostic[] +} + +export type Renderable = { + collection: string + id: string + content: () => Promise<{ node: Node }> +} diff --git a/src/lib/site/activity.ts b/src/lib/site/activity.ts new file mode 100644 index 0000000..2329977 --- /dev/null +++ b/src/lib/site/activity.ts @@ -0,0 +1,16 @@ +import { trailOf } from './navigation' +import type { Activity, Article, Release } from './types' + +const LENGTH = 10 + +export function activityOf(work: Article, releases: Release[]): Activity[] { + return releases + .slice(0, LENGTH) + .reverse() + .map((release) => ({ + release, + touched: release.changes.some( + (change) => change.article && trailOf(change.article)[0] === work, + ), + })) +} diff --git a/src/lib/site/directory.ts b/src/lib/site/directory.ts new file mode 100644 index 0000000..8c2972d --- /dev/null +++ b/src/lib/site/directory.ts @@ -0,0 +1,35 @@ +import type { DirectoryEntry, Site } from './types' + +const depthOf = (path: string) => path.split('/').filter(Boolean).length + +const byPath = (a: DirectoryEntry, b: DirectoryEntry) => a.path.localeCompare(b.path) + +export function directoryOf(site: Site): DirectoryEntry[] { + const articles = [...site.articles.values()] + .filter((article) => article.listed) + .map((article) => ({ + title: article.title, + path: article.path, + collection: article.collection, + parent: article.parent?.title ?? null, + depth: depthOf(article.path), + })) + + const pages = [...site.pages.values()].map((page) => ({ + title: page.title, + path: page.path, + collection: page.collection, + parent: null, + depth: depthOf(page.path), + })) + + const tags = [...site.tags.values()].map((tag) => ({ + title: tag.title, + path: tag.path, + collection: tag.collection, + parent: null, + depth: depthOf(tag.path), + })) + + return [...articles, ...pages, ...tags].sort(byPath) +} diff --git a/src/lib/site/index.ts b/src/lib/site/index.ts new file mode 100644 index 0000000..6b87fe5 --- /dev/null +++ b/src/lib/site/index.ts @@ -0,0 +1,88 @@ +import { PAGES, TAGS } from '../constants/collections' +import { loadContent } from '../content/load' +import type { Content } from '../content/types' +import { createDiagnostics } from '../diagnostics' +import type { Diagnostics } from '../diagnostics/types' +import { collectReleases } from './releases' +import { + isBuilt, + newestFirst, + segmentOf, + segmentProblem, + siblingOrder, + type Visibility, +} from './rules' +import { placeArticles } from './tree' +import type { Article, Page, Routed, Site, Tag } from './types' +import { validate } from './validate' + +function placePages(content: Content, visibility: Visibility, problems: Diagnostics) { + const pages = new Map() + for (const [id, entry] of content.pages) { + if (!isBuilt(entry.status, visibility)) continue + const segment = segmentOf(entry) + const problem = segmentProblem(segment, true) + if (problem) problems.error(PAGES, id, problem) + else pages.set(id, { ...entry, collection: PAGES, id, path: `/${segment}` }) + } + return pages +} + +function collectTags(content: Content, articles: Map) { + const listed = [...articles.values()].filter((article) => article.listed) + const tags = new Map() + for (const [id, entry] of content.tags) { + const tagged = listed.filter((article) => article.tags.includes(id)).sort(newestFirst) + if (tagged.length) + tags.set(id, { ...entry, collection: TAGS, id, path: `/${TAGS}/${id}`, articles: tagged }) + } + return tags +} + +function indexUrls(routed: Routed[], problems: Diagnostics) { + const byPath = new Map() + for (const node of routed) { + const taken = byPath.get(node.path) + if (!taken) byPath.set(node.path, node) + else + problems.error( + node.collection, + node.id, + `URL ${node.path} is taken by ${taken.collection}/${taken.id}.`, + ) + } + return byPath +} + +export function buildSite(content: Content, visibility: Visibility): Site { + const problems = createDiagnostics() + const articles = placeArticles(content, visibility, problems) + const pages = placePages(content, visibility, problems) + const tags = collectTags(content, articles) + const releases = collectReleases(content, articles, problems) + validate(articles.values(), content, problems) + const byPath = indexUrls([...articles.values(), ...pages.values()], problems) + const roots = [...articles.values()] + .filter((article) => article.listed && !article.parent) + .sort(siblingOrder) + + return { + articles, + pages, + tags, + releases, + byPath, + known: new Set(content.articles.keys()), + roots, + diagnostics: [...content.diagnostics, ...problems.list], + } +} + +let cached: Promise | null = null + +export function getSite(): Promise { + const build = async () => + buildSite(await loadContent(), { drafts: import.meta.env?.DEV ?? false }) + if (!import.meta.env?.PROD) return build() + return (cached ??= build()) +} diff --git a/src/lib/site/links.ts b/src/lib/site/links.ts new file mode 100644 index 0000000..f8b026b --- /dev/null +++ b/src/lib/site/links.ts @@ -0,0 +1,10 @@ +import type { LinkTarget } from '../markdoc/types' +import type { Site } from './types' + +export const linkTo = + (site: Site) => + (id: string): LinkTarget => { + const article = site.articles.get(id) + if (article) return { path: article.path } + return site.known.has(id) ? { path: null } : undefined + } diff --git a/src/lib/site/meta.ts b/src/lib/site/meta.ts new file mode 100644 index 0000000..f1b0927 --- /dev/null +++ b/src/lib/site/meta.ts @@ -0,0 +1,18 @@ +import type { Article, Page, Routed } from './types' + +export type Meta = { + title: string + description?: string + image?: string + noindex: boolean +} + +const cover = (node: Routed) => + node.collection === 'articles' ? (node as Article).summary.cover.image : null + +export const metaOf = (node: Article | Page): Meta => ({ + title: node.title, + description: node.summary.excerpt || undefined, + image: cover(node) ?? undefined, + noindex: node.noindex, +}) diff --git a/src/lib/site/navigation.ts b/src/lib/site/navigation.ts new file mode 100644 index 0000000..9938799 --- /dev/null +++ b/src/lib/site/navigation.ts @@ -0,0 +1,52 @@ +import type { Article } from './types' + +export type Sequence = { + parent: Article + siblings: Article[] + position: number + previous: Article | null + next: Article | null +} + +export function sequenceOf(article: Article): Sequence | null { + const parent = article.parent + if (!parent?.route.sequence) return null + + const siblings = parent.children + const position = siblings.indexOf(article) + if (position === -1 || siblings.length < 2) return null + + return { + parent, + siblings, + position, + previous: siblings[position - 1] ?? null, + next: siblings[position + 1] ?? null, + } +} + +export function trailOf(article: Article): Article[] { + const trail: Article[] = [] + for (let node: Article | null = article; node; node = node.parent) trail.unshift(node) + return trail +} + +export type ContextGroup = { + heading: Article | null + entries: Article[] +} + +export function contextOf(article: Article | null, roots: Article[]): ContextGroup[] { + if (!article) return [{ heading: null, entries: roots }] + + const { parent } = article + const siblings = (parent ? parent.children : roots).filter((entry) => entry !== article) + + const groups: ContextGroup[] = [ + { heading: parent?.parent ?? null, entries: parent?.listed ? [parent] : [] }, + { heading: parent, entries: siblings }, + { heading: article, entries: article.children }, + ] + + return groups.filter((group) => group.entries.length > 0) +} diff --git a/src/lib/site/releases.ts b/src/lib/site/releases.ts new file mode 100644 index 0000000..cf048fb --- /dev/null +++ b/src/lib/site/releases.ts @@ -0,0 +1,59 @@ +import { RELEASES } from '../constants/collections' +import { parseDate } from '../content/dates' +import type { Content } from '../content/types' +import type { Diagnostics } from '../diagnostics/types' +import type { Article, Release } from './types' + +const partsOf = (version: string) => version.split('.').map(Number) + +function compareVersions(a: string, b: string): number { + const [left, right] = [partsOf(a), partsOf(b)] + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0) + if (difference) return difference + } + return 0 +} + +const releaseOrder = (a: Release, b: Release) => compareVersions(b.version, a.version) + +export function collectReleases( + content: Content, + articles: Map, + problems: Diagnostics, +): Release[] { + const releases: Release[] = [] + + for (const [id, entry] of content.releases) { + const date = parseDate(entry.date) + if (!date) { + problems.error(RELEASES, id, 'Needs a date') + continue + } + + const release: Release = { id, version: entry.version, date, changes: [] } + for (const { kind, article: target, text } of entry.changes) { + const article = target ? articles.get(target) : null + if (target && !article) { + if (!content.articles.has(target)) + problems.error(RELEASES, id, `Article "${target}" does not exist`) + continue + } + release.changes.push({ kind, article: article ?? null, text, release }) + } + + if (release.changes.length) releases.push(release) + } + + releases.sort(releaseOrder) + releases.forEach((release, i) => { + const older = releases[i + 1] + if (older && release.date < older.date) + problems.warning(RELEASES, release.id, `Dated before v${older.version}`) + }) + + for (const release of releases) + for (const change of release.changes) change.article?.changes.push(change) + + return releases +} diff --git a/src/lib/site/rules.ts b/src/lib/site/rules.ts new file mode 100644 index 0000000..4f33a53 --- /dev/null +++ b/src/lib/site/rules.ts @@ -0,0 +1,35 @@ +import { TAGS } from '../constants/collections' +import type { Status } from '../constants/status' +import { slugify } from '../content/slug' +import type { Article } from './types' + +export type Visibility = { + drafts: boolean +} + +export const isBuilt = (status: Status, { drafts }: Visibility) => status !== 'draft' || drafts + +export const isListed = (status: Status, { drafts }: Visibility) => + status === 'published' || (drafts && status === 'draft') + +export const segmentOf = (entry: { title: string; route: { segment: string } }) => + entry.route.segment || slugify(entry.title) + +const RESERVED = [TAGS, 'keystatic', 'api'] + +export function segmentProblem(segment: string, atRoot: boolean): string | null { + if (!segment) return 'The title has no URL-safe character. Set an URL segment.' + if (atRoot && RESERVED.includes(segment)) + return `"/${segment}" is reserved. Set an URL segment` + return null +} + +const time = (date: Date | null) => date?.getTime() ?? 0 +const UNINDEXED = Number.MAX_SAFE_INTEGER + +export const newestFirst = (a: Article, b: Article) => time(b.publishedAt) - time(a.publishedAt) + +export const siblingOrder = (a: Article, b: Article) => + (a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) || + newestFirst(a, b) || + a.title.localeCompare(b.title) diff --git a/src/lib/site/tree.ts b/src/lib/site/tree.ts new file mode 100644 index 0000000..2d13a41 --- /dev/null +++ b/src/lib/site/tree.ts @@ -0,0 +1,76 @@ +import { ARTICLES } from '../constants/collections' +import { parseDate } from '../content/dates' +import type { Content } from '../content/types' +import type { Diagnostics } from '../diagnostics/types' +import { + isBuilt, + isListed, + segmentOf, + segmentProblem, + siblingOrder, + type Visibility, +} from './rules' +import type { Article } from './types' + +export function placeArticles( + content: Content, + visibility: Visibility, + problems: Diagnostics, +): Map { + const placed = new Map() + const visiting = new Set() + + const skip = (id: string, level: 'error' | 'warning', message: string): Article | null => { + problems[level](ARTICLES, id, message) + placed.set(id, null) + return null + } + + const place = (id: string): Article | null => { + if (placed.has(id)) return placed.get(id) ?? null + const entry = content.articles.get(id) + if (!entry || !isBuilt(entry.status, visibility)) return null + if (visiting.has(id)) return skip(id, 'error', 'Its parent chain loops back to it') + + const parentId = entry.route.parent + visiting.add(id) + const parent = parentId ? place(parentId) : null + visiting.delete(id) + if (placed.has(id)) return placed.get(id) ?? null + if (parentId && !parent) { + const parentEntry = content.articles.get(parentId) + if (!parentEntry) return skip(id, 'error', `Parent "${parentId}" does not exist.`) + const why = isBuilt(parentEntry.status, visibility) ? 'has no URL' : 'is a draft' + return skip(id, 'warning', `Not build: parent "${parentId}" ${why}`) + } + + const segment = segmentOf(entry) + const problem = segmentProblem(segment, !parent) + if (problem) return skip(id, 'error', problem) + + const article: Article = { + ...entry, + collection: ARTICLES, + id, + path: `${parent?.path ?? ''}/${segment}`, + listed: isListed(entry.status, visibility), + publishedAt: parseDate(entry.published), + parent, + children: [], + changes: [], + } + placed.set(id, article) + return article + } + + for (const id of content.articles.keys()) place(id) + + const articles = new Map() + for (const [id, article] of placed) if (article) articles.set(id, article) + + for (const article of articles.values()) + if (article.listed) article.parent?.children.push(article) + for (const article of articles.values()) article.children.sort(siblingOrder) + + return articles +} diff --git a/src/lib/site/types.ts b/src/lib/site/types.ts new file mode 100644 index 0000000..4e025a5 --- /dev/null +++ b/src/lib/site/types.ts @@ -0,0 +1,68 @@ +import type { ChangeType } from '../constants/changelog' +import { ARTICLES, PAGES, TAGS } from '../constants/collections' +import type { ArticleEntry, PageEntry, TagEntry } from '../content/types' +import type { Diagnostic } from '../diagnostics/types' + +export type Article = ArticleEntry & { + collection: typeof ARTICLES + id: string + path: string + listed: boolean + publishedAt: Date | null + parent: Article | null + children: Article[] + changes: Change[] +} + +export type Page = PageEntry & { + collection: typeof PAGES + id: string + path: string +} + +export type Tag = TagEntry & { + collection: typeof TAGS + id: string + path: string + articles: Article[] +} + +export type Release = { + id: string + version: string + date: Date + changes: Change[] +} + +export type Change = { + kind: ChangeType + article: Article | null + text: string + release: Release +} + +export type Routed = Article | Page + +export type Site = { + articles: Map + pages: Map + tags: Map + releases: Release[] + byPath: Map + known: Set + roots: Article[] + diagnostics: Diagnostic[] +} + +export type DirectoryEntry = { + title: string + path: string + collection: typeof ARTICLES | typeof PAGES | typeof TAGS + parent: string | null + depth: number +} + +export type Activity = { + release: Release + touched: boolean +} diff --git a/src/lib/site/validate.ts b/src/lib/site/validate.ts new file mode 100644 index 0000000..3c5c9f8 --- /dev/null +++ b/src/lib/site/validate.ts @@ -0,0 +1,19 @@ +import { ARTICLES } from '../constants/collections' +import type { Content } from '../content/types' +import type { Diagnostics } from '../diagnostics/types' +import type { Article } from './types' + +export function validate(articles: Iterable
, content: Content, problems: Diagnostics) { + for (const { id, status, summary, publishedAt, tags } of articles) { + const { excerpt, cover } = summary + const error = (message: string) => problems.error(ARTICLES, id, message) + const warn = (message: string) => problems.warning(ARTICLES, id, message) + + if (status !== 'draft' && !excerpt) error('Needs an excerpt') + if (status !== 'draft' && !publishedAt) error('Needs a published date') + if (cover.image && !cover.alt) error('Cover image needs alt text') + if (!cover.image && (cover.alt || cover.caption || cover.credit)) + warn('The cover has text but not image!') + for (const tag of tags) if (!content.tags.has(tag)) error(`Tag "${tag}" does not exist`) + } +} diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..7ed37e4 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,292 @@ +--- +import Overline from '@/components/post/Header/Overline.astro' +import BaseLayout from '@/layouts/BaseLayout.astro' +import { toMilitaryDTG } from '@/lib/content/dates' +import { getSite } from '@/lib/site' + +const site = await getSite() + +const stamp = toMilitaryDTG(new Date()) + +const disposition = [ + { + title: 'Return to index', + path: '/', + }, + ...site.roots.map((root) => ({ + title: `Browser ${root.title}`, + path: root.path, + })), +] +--- + + +
+ + + {stamp} + + + +
+ +

Not on record

+

+ There is no entry under this address. It was never filed, or it has been struck from the + register. +

+ +
+
+

01 ⟫ Requested

+

+ + + — + +

+
+ +
+

02 ⟫ Status

+

✖ Not found

+
+ +
+

03 ⟫ Checked

+

+ {stamp} +

+
+ +
+

04 ⟫ Disposition

+ +
+
+
+
+
+ + + + diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro new file mode 100644 index 0000000..596c851 --- /dev/null +++ b/src/pages/[...path].astro @@ -0,0 +1,29 @@ +--- +import type { GetStaticPaths } from 'astro' + +import ArticleLayout from '@/layouts/general/ArticleLayout.astro' +import PageLayout from '@/layouts/general/PageLayout.astro' +import { assertNoErrors } from '@/lib/diagnostics/report' +import { render } from '@/lib/markdoc' +import { getSite } from '@/lib/site' +import { linkTo } from '@/lib/site/links' + +export const getStaticPaths = (async () => { + const site = await getSite() + if (import.meta.env.PROD) assertNoErrors(site.diagnostics) + return [...site.byPath.keys()].map((path) => ({ params: { path: path.slice(1) } })) +}) satisfies GetStaticPaths + +const site = await getSite() +const node = site.byPath.get(`/${Astro.params.path}`) +if (!node) return new Response(null, { status: 404 }) + +const rendered = await render(node, linkTo(site)) +if (import.meta.env.PROD) assertNoErrors(rendered.diagnostics) +--- + +{node.collection === 'articles' ? ( + +) : ( + +)} diff --git a/src/pages/api/site-index.json.ts b/src/pages/api/site-index.json.ts new file mode 100644 index 0000000..d759cb7 --- /dev/null +++ b/src/pages/api/site-index.json.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro' + +import { getSite } from '@/lib/site' +import { directoryOf } from '@/lib/site/directory' + +export const GET: APIRoute = async () => Response.json(directoryOf(await getSite())) diff --git a/src/pages/index.astro b/src/pages/index.astro index c7d8da6..bd8da9a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,8 +1,28 @@ --- +import Changelog from '@/components/home/Changelog.astro' +import HomeHero from '@/components/home/HomeHero.astro' +import HomeSection from '@/components/home/HomeSection.astro' +import WorkList from '@/components/home/WorkList.astro' import BaseLayout from '@/layouts/BaseLayout.astro' +import { SITE } from '@/lib/constants/site' +import { getSite } from '@/lib/site' + +const site = await getSite() +const entries = [...site.articles.values()].filter((article) => article.listed).length +const releases = site.releases.slice(0, 3) +const hasChangelog = releases.length > 0 --- - -

I AM TEST

-

ARE YOU TEST?

+ + + {hasChangelog && ( + + + + )} + {site.roots.length > 0 && ( + + + + )} diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..64fa5fb --- /dev/null +++ b/src/pages/sitemap.xml.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro' + +import { ARTICLES } from '@/lib/constants/collections' +import { isoDate } from '@/lib/content/dates' +import { getSite } from '@/lib/site' +import type { Routed } from '@/lib/site/types' + +type Url = { + path: string + modified: Date | null +} + +const modifiedOf = (node: Routed): Date | null => + node.collection === ARTICLES ? (node.changes[0]?.release.date ?? node.publishedAt) : null + +export const GET: APIRoute = async ({ site: origin }) => { + if (!origin) throw new Error('sitemap.xml needs `site` in astro.config.mjs') + + const site = await getSite() + const urls: Url[] = [ + { path: '/', modified: null }, + ...[...site.byPath.values()] + .filter((node) => node.status === 'published' && !node.noindex) + .map((node) => ({ path: node.path, modified: modifiedOf(node) })), + ...[...site.tags.values()].map((tag) => ({ path: tag.path, modified: null })), + ] + + const body = [ + '', + '', + ...urls.map(({ path, modified }) => { + const lastmod = modified ? `${isoDate(modified)}` : '' + return ` ${new URL(path, origin)}${lastmod}` + }), + '', + ].join('\n') + + return new Response(body, { headers: { 'Content-Type': 'application/xml' } }) +} diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro new file mode 100644 index 0000000..eef5ab9 --- /dev/null +++ b/src/pages/tags/[tag].astro @@ -0,0 +1,17 @@ +--- +import type { GetStaticPaths } from 'astro' + +import TagLayout from '@/layouts/general/TagLayout.astro' +import { getSite } from '@/lib/site' + +export const getStaticPaths = (async () => { + const site = await getSite() + return [...site.tags.keys()].map((tag) => ({ params: { tag } })) +}) satisfies GetStaticPaths + +const site = await getSite() +const tag = site.tags.get(Astro.params.tag) +if (!tag) return new Response(null, { status: 404 }) +--- + + diff --git a/src/styles/base/colors.css b/src/styles/base/colors.css index 0a8bd86..c6fa4fb 100644 --- a/src/styles/base/colors.css +++ b/src/styles/base/colors.css @@ -1,5 +1,6 @@ @layer tokens { - :root { + :root, + .light { /* === PALETTE === */ /* == Ink == */ @@ -45,6 +46,7 @@ --color-text-quiet: var(--color-palette-ash); --color-text-disabled: var(--color-palette-stone); --color-text-inverse: var(--color-palette-paper); + --color-text-on-accent: var(--color-palette-black-ink); /* == Surfaces == */ --color-surface-base: var(--color-palette-stock); @@ -111,4 +113,45 @@ --color-skeleton-base: var(--color-surface-elevated-2); --color-skeleton-shimmer: var(--color-surface-elevated-1); } + + .dark { + /* == Text == */ + --color-text-primary: var(--color-palette-paper); + --color-text-secondary: var(--color-palette-stock); + --color-text-tertiary: var(--color-palette-parchment); + --color-text-quarternary: var(--color-palette-bone); + --color-text-quiet: var(--color-palette-sand); + --color-text-disabled: var(--color-palette-stone); + --color-text-inverse: var(--color-palette-black-ink); + --color-text-on-accent: var(--color-palette-black-ink); + + /* == Surfaces: elevation still means lighter == */ + --color-surface-base: var(--color-palette-black-ink); + --color-surface-elevated-1: var(--color-palette-charcoal); + --color-surface-elevated-2: var(--color-palette-slate); + --color-surface-elevated-3: var(--color-palette-graphite); + --color-surface-inverse: var(--color-palette-paper); + --color-surface-selected: var(--color-palette-slate); + --color-surface-hover: var(--color-palette-charcoal); + + /* == Borders == */ + --color-border-hairline: var(--color-palette-charcoal); + --color-border-subtle: var(--color-palette-slate); + --color-border-normal: var(--color-palette-graphite); + --color-border-strong: var(--color-palette-paper); + + /* == Apparatus: ink is invisible on a dark page == */ + --color-rule: var(--color-palette-slate); + --color-rule-strong: var(--color-palette-paper); + + /* == Overlays tint with light, not ink == */ + --color-overlay-light: oklch(98.8% 0.005 85deg / 8%); + --color-overlay-medium: oklch(98.8% 0.005 85deg / 30%); + --color-overlay-heavy: oklch(98.8% 0.005 85deg / 60%); + + /* == Shadow == */ + --shadow-card: var(--size-1) var(--size-1) 0 var(--color-palette-paper); + --shadow-raised: var(--size-2) var(--size-2) 0 var(--color-palette-paper); + --shadow-pressed: 0 0 0 var(--color-palette-paper); + } } diff --git a/src/styles/base/index.css b/src/styles/base/index.css new file mode 100644 index 0000000..823c9ab --- /dev/null +++ b/src/styles/base/index.css @@ -0,0 +1,3 @@ +@import url("foundation.css"); +@import url("colors.css"); +@import url("primitives.css"); diff --git a/src/styles/base/primitives.css b/src/styles/base/primitives.css index 49fa0ba..e60dc67 100644 --- a/src/styles/base/primitives.css +++ b/src/styles/base/primitives.css @@ -15,6 +15,7 @@ --type-8: 2.1875rem; /* 35px */ --type-9: 2.5rem; /* 40px */ --type-10: 2.75rem; /* 44px */ + --type-slab: clamp(4rem, 9vw, 7.5rem); /* == Line heights == */ --leading--3: 1.6; @@ -58,6 +59,7 @@ --tracking-eyebrow: 0.16em; /* section eyebrows, zone labels */ --tracking-plate: 0.08em; /* plate numbers */ --tracking-display: 0.01em; /* Blaka h1 */ + --tracking-subtitle: 0.0875em; /* standfirst under a title */ } .content { @@ -108,7 +110,6 @@ --tracking-8: -0.0225em; --tracking-9: -0.0237em; --tracking-10: -0.0247em; - --leading-prose: 1.6; } :root { @@ -122,7 +123,7 @@ --font-weight-body: 400; --font-weight-emphasis: 700; --font-weight-label: 600; - --font-weight-heading: 800; + --font-weight-heading: 900; /* === SIZE === */ --size-0: 0; @@ -178,6 +179,11 @@ --slot-modifier: 1.125rem; /* the +2 / −1 column in contributions */ --slot-marker: var(--size-5); /* footnote marker column */ --slot-roll-range: 1.5rem; /* the 4–5 column in a table row */ + --slot-section-number: 5ch; + --slot-prose-marker: 1.625rem; + --slot-prose-gap: var(--size-6); + --slot-prose-bar: var(--size-8); + --slot-box-cell: 8.5rem; /* ═══ MOTION ═══ */ --motion-fast: 150ms; /* state change: hover, focus, press */ @@ -188,15 +194,12 @@ --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* ═══ LAYOUT ═══ */ - --layout-max-width: 74rem; /* the tool shell */ - --layout-content-width: minmax(0, 78ch); /* article measure */ - --layout-margin-width: minmax(10ch, 1fr); - --layout-step-columns: 2; - --layout-gutter: var(--size-6); /* between step cards */ - --layout-section-gap: var(--size-9); /* header to grid */ + --layout-wrapper-width: 74rem; + --layout-rail-width: 12.5rem; + --layout-content-width: minmax(0, 62ch); + --layout-margin-width: 18rem; + --layout-gutter: var(--size-12); --layout-page-margin: var(--size-10); - --layout-tier-height: 2.25rem; --layout-header-height: 3.125rem; - --layout-card-min-height: 6.5rem; /* stops grid reflow on resolve */ } } diff --git a/src/styles/components/blocks/callout.css b/src/styles/components/blocks/callout.css new file mode 100644 index 0000000..04f34d5 --- /dev/null +++ b/src/styles/components/blocks/callout.css @@ -0,0 +1,84 @@ +@layer components.blocks { + .callout { + @mixin my var(--space-9); + counter-increment: box; + + display: grid; + grid-template-columns: var(--slot-box-cell) minmax(0, 1fr); + + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-surface-elevated-1); + box-shadow: var(--shadow-card); + } + + .callout__kind { + @mixin typo_label; + @mixin ma var(--size-0); + @mixin py var(--size-6); + @mixin px var(--size-7); + + display: flex; + flex-direction: column; + justify-content: space-between; + + border-inline-end: var(--border-card) solid var(--color-border-strong); + + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + + &::after { + content: "Box " counter(sec, upper-roman) "." counter(box); + + font-family: var(--font-mono); + font-size: var(--type--3); + font-weight: var(--font-weight-body); + color: var(--color-text-quarternary); + } + } + + .callout__title { + @mixin typo_heading-4; + @mixin mt var(--size-0); + @mixin mb var(--size-5); + + font-size: var(--type-0); + } + + .callout__body { + @mixin py var(--size-6); + @mixin px var(--size-8); + + & > :first-child { + margin-block-start: var(--size-0); + } + + & > :last-child { + margin-block-end: var(--size-0); + } + } + + .callout--optional { + border-width: var(--border-hairline); + background-color: transparent; + box-shadow: none; + + & .callout__kind { + border-inline-end: var(--border-hairline) solid var(--color-border-normal); + color: var(--color-text-primary); + background-color: transparent; + + &::after { + color: var(--color-text-quiet); + } + } + + & .callout__title { + @mixin typo_label; + + font-size: var(--type--2); + color: var(--color-text-quiet); + } + } +} diff --git a/src/styles/components/blocks/code.css b/src/styles/components/blocks/code.css new file mode 100644 index 0000000..60d10cc --- /dev/null +++ b/src/styles/components/blocks/code.css @@ -0,0 +1,49 @@ +@layer components.blocks { + .code { + @mixin my var(--space-9); + border: var(--border-card) solid var(--color-border-strong); + background-color: var(--color-surface-elevated-1); + } + + .code__lang { + @mixin typo_label; + @mixin py var(--size-3); + @mixin px var(--size-7); + @mixin border-b var(--border-card), solid, var(--color-border-strong); + display: block; + + font-family: var(--font-mono); + font-size: var(--type--3); + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + } + + .code pre { + @mixin ma var(--size-0); + @mixin py var(--size-7); + @mixin px var(--size-8); + + overflow-x: auto; + border: 0; + tab-size: 2; + background-color: transparent; + } + + .code pre code { + @mixin pa var(--size-0); + + font: inherit; + background-color: transparent; + } + + .code .shiki, + .code .shiki span { + color: var(--shiki-light); + } + + .dark .code .shiki, + .dark .code .shiki span { + color: var(--shiki-dark); + } +} diff --git a/src/styles/components/blocks/figure.css b/src/styles/components/blocks/figure.css new file mode 100644 index 0000000..18f75ad --- /dev/null +++ b/src/styles/components/blocks/figure.css @@ -0,0 +1,101 @@ +@layer components.blocks { + .figure { + @mixin my var(--space-10); + + & img { + display: block; + + aspect-ratio: var(--figure-ratio, auto); + width: 100%; + height: auto; + + object-fit: cover; + } + } + + .ratio--21-9 { + --figure-ratio: 21 / 9; + } + + .ratio--16-9 { + --figure-ratio: 16 / 9; + } + + .ratio--3-2 { + --figure-ratio: 3 / 2; + } + + .ratio--4-3 { + --figure-ratio: 4 / 3; + } + + .ratio--1-1 { + --figure-ratio: 1 / 1; + } + + .ratio--4-5 { + --figure-ratio: 4 / 5; + } + + .figure--inline { + & .figure__caption { + @media (--bp-desktop) { + @mixin mt var(--size-0); + + align-self: end; + } + } + + @media (--bp-desktop) { + @mixin me calc(-1 * (var(--layout-gutter) + var(--layout-margin-width))); + + display: grid; + grid-template-columns: minmax(0, 1fr) var(--layout-margin-width); + column-gap: var(--layout-gutter); + } + } + + .figure--wide { + .figure__caption { + @mixin mt var(--size-0); + @mixin py var(--size-4); + @mixin px var(--size-6); + + background-color: var(--color-surface-base); + } + + @media (--bp-desktop) { + @mixin me calc(-1 * (var(--layout-gutter) + var(--layout-margin-width))); + } + } + + .figure__caption { + @mixin typo_label; + @mixin mt var(--size-3); + + counter-increment: fig; + + display: flex; + flex-wrap: wrap; + gap: var(--size-5); + + color: var(--color-text-secondary); + + &::before { + content: "Fig. " counter(sec, upper-roman) "." counter(fig) " ⟫"; + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + white-space: nowrap; + } + } + + .figure__credit { + color: var(--color-text-quiet); + } + + .effect--xeroxed { + & img { + filter: grayscale(1) contrast(10) brightness(50); + } + } +} diff --git a/src/styles/components/blocks/footnote.css b/src/styles/components/blocks/footnote.css new file mode 100644 index 0000000..f9e076d --- /dev/null +++ b/src/styles/components/blocks/footnote.css @@ -0,0 +1,108 @@ +@layer components.blocks { + .footnote-ref { + font-family: var(--font-mono); + font-size: 0.7em; + font-weight: var(--font-weight-emphasis); + line-height: 1; + + & a { + @mixin px var(--size-1); + + color: var(--color-text-primary); + text-decoration: none; + + &:hover { + color: var(--color-primary); + } + + &:focus-visible { + @mixin focus-ring; + } + } + } + + .footnotes { + --footnote-marker: 1.5rem; + + @mixin mt var(--space-10); + @mixin pt var(--space-6); + @mixin border-t var(--border-boundary), solid, var(--color-border-strong); + + counter-reset: fn; + + & .footnotes__title { + @mixin typo_label; + @mixin mt var(--size-0); + @mixin mb var(--size-5); + } + + & ol { + @mixin ma var(--size-0); + @mixin pl var(--size-0); + + display: flex; + flex-direction: column; + gap: var(--size-4); + list-style: none; + + & li { + @mixin typo_body-small; + + counter-increment: fn; + + display: grid; + grid-template-columns: var(--footnote-marker) minmax(0, 1fr); + column-gap: var(--slot-prose-gap); + + color: var(--color-text-secondary); + + &::before { + content: counter(fn); + font-family: var(--font-mono); + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + } + + &:target { + color: var(--color-text-primary); + + &::before { + color: var(--color-primary); + } + } + } + } + + & .footnotes__note { + @mixin ma var(--size-0); + } + + & a { + color: var(--color-text-link); + text-decoration: underline; + text-decoration-thickness: var(--size-1); + text-underline-offset: var(--size-2); + + &:visited { + color: var(--color-text-link-visited); + } + + &:hover { + color: var(--color-text-link-hover); + } + + &:focus-visible { + @mixin focus-ring; + } + } + + & a.footnotes__back { + color: var(--color-text-quiet); + text-decoration: none; + + &:hover { + color: var(--color-text-primary); + } + } + } +} diff --git a/src/styles/components/blocks/index.css b/src/styles/components/blocks/index.css new file mode 100644 index 0000000..9aef59c --- /dev/null +++ b/src/styles/components/blocks/index.css @@ -0,0 +1,11 @@ +@import url("quote.css"); +@import url("lead.css"); +@import url("callout.css"); +@import url("reveal.css"); +@import url("table-wrapper.css"); +@import url("footnote.css"); +@import url("sidenote.css"); +@import url("figure.css"); +@import url("code.css"); +@import url("terms.css"); +@import url("spoiler.css"); diff --git a/src/styles/components/blocks/lead.css b/src/styles/components/blocks/lead.css new file mode 100644 index 0000000..ca1f9eb --- /dev/null +++ b/src/styles/components/blocks/lead.css @@ -0,0 +1,29 @@ +@layer components.blocks { + .lead { + --prose-line: 1lh; + + @mixin my var(--space-6); + + display: flow-root; + font-size: var(--type-1); + line-height: var(--leading-prose-tight); + } + + .lead__cap { + @mixin me var(--size-8); + + float: inline-start; + display: grid; + place-items: center; + + aspect-ratio: 1; + width: calc(var(--prose-line) * 3); + + font-family: var(--font-display); + font-size: calc(var(--prose-line) * 1.9); + line-height: 1; + color: var(--color-text-inverse); + + background-color: var(--color-surface-inverse); + } +} diff --git a/src/styles/components/blocks/quote.css b/src/styles/components/blocks/quote.css new file mode 100644 index 0000000..b677d9b --- /dev/null +++ b/src/styles/components/blocks/quote.css @@ -0,0 +1,79 @@ +@layer components.blocks { + .quote { + @mixin my var(--space-11); + @mixin py var(--space-9); + + display: grid; + gap: var(--size-9); + border-block: var(--border-boundary) solid var(--color-border-strong); + } + + .prose { + & .quote { + & blockquote { + @mixin typo_heading-3; + @mixin ma var(--size-0); + @mixin ps var(--size-0); + + font-size: var(--type-4); + font-style: normal; + line-height: 1.1; + color: var(--color-text-primary); + text-wrap: balance; + } + } + + & .quote__source { + & a { + color: inherit; + text-decoration-color: var(--color-primary); + text-decoration-thickness: var(--size-px); + text-underline-offset: var(--size-2); + + &:hover, + &:focus-visible { + color: var(--color-primary); + } + } + } + } + + .quote__citation { + @mixin typo_label; + + display: flex; + flex-wrap: wrap; + gap: var(--size-4); + + font-size: var(--type--2); + color: var(--color-text-quarternary); + letter-spacing: var(--tracking-plate); + } + + .quote__who { + color: var(--color-text-primary); + + &::before { + content: "⟫ " / ""; + } + } + + .quote__source { + font-style: normal; + } + + @media (--bp-desktop) { + .quote { + @mixin me calc(-1 * (var(--layout-gutter) + var(--layout-margin-width))); + + grid-template-columns: minmax(0, 1fr) var(--layout-margin-width); + column-gap: var(--layout-gutter); + } + + .quote__citation { + flex-direction: column; + gap: var(--size-2); + align-self: end; + } + } +} diff --git a/src/styles/components/blocks/reveal.css b/src/styles/components/blocks/reveal.css new file mode 100644 index 0000000..60d5871 --- /dev/null +++ b/src/styles/components/blocks/reveal.css @@ -0,0 +1,110 @@ +@layer components.blocks { + .reveal { + @mixin my var(--space-9); + counter-increment: box; + + display: grid; + grid-template-columns: var(--slot-box-cell) minmax(0, 1fr); + + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-surface-elevated-1); + box-shadow: var(--shadow-card); + } + + .reveal__summary { + cursor: pointer; + + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .reveal__kind { + @mixin typo_label; + @mixin py var(--size-6); + @mixin px var(--size-7); + + display: flex; + flex-direction: column; + align-self: stretch; + justify-content: space-between; + + border-inline-end: var(--border-card) solid var(--color-border-strong); + + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + } + + .reveal__kind::after { + content: "Box " counter(sec, upper-roman) "." counter(box); + + font-family: var(--font-mono); + font-size: var(--type--3); + font-weight: var(--font-weight-body); + color: var(--color-text-quarternary); + } + + .reveal__head { + @mixin py var(--size-6); + @mixin px var(--size-8); + + display: flex; + gap: var(--size-8); + align-items: center; + justify-content: space-between; + } + + .reveal__title { + @mixin typo_heading-4; + + font-size: var(--type-0); + } + + .reveal__action { + @mixin typo_label; + + font-size: var(--type--2); + color: var(--color-text-quiet); + white-space: nowrap; + + &::after { + content: " ⟫"; + } + } + + .reveal[open] .reveal__action { + visibility: hidden; + } + + .reveal__body { + @mixin py var(--size-6); + @mixin px var(--size-8); + + grid-column: 2; + + & > :first-child { + margin-block-start: var(--size-0); + } + + & > :last-child { + margin-block-end: var(--size-0); + } + } + + .reveal::details-content { + grid-column: 2; + } +} diff --git a/src/styles/components/blocks/sidenote.css b/src/styles/components/blocks/sidenote.css new file mode 100644 index 0000000..e5957e2 --- /dev/null +++ b/src/styles/components/blocks/sidenote.css @@ -0,0 +1,79 @@ +@layer components.blocks { + .sidenote-ref { + @mixin px var(--size-1); + + font-family: var(--font-mono); + font-size: 0.75em; + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + text-decoration: none; + + &:hover { + color: var(--color-primary); + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .sidenote { + @mixin typo_body-small; + @mixin pt var(--size-3); + @mixin border-t var(--border-card), solid, var(--color-border-strong); + + color: var(--color-text-secondary); + + /* Hangs, so the first line starts against the marker. */ + & .sidenote__marker { + @mixin me var(--size-4); + + float: inline-start; + font-family: var(--font-mono); + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + } + + &:target { + color: var(--color-text-primary); + + & .sidenote__marker { + color: var(--color-primary); + } + } + } + + .sidenote--optional { + @mixin border-t var(--border-hairline), solid, var(--color-border-normal); + + color: var(--color-text-tertiary); + + & .sidenote__marker { + color: var(--color-text-quiet); + } + } + + .sidenote--example { + & .sidenote__marker { + color: var(--color-primary); + } + } + + @media (--bp-tablet-down) { + .sidenote { + @mixin my var(--space-6); + + display: block; + } + } + + @media (--bp-desktop) { + .sidenote { + @mixin my var(--size-0); + + position: absolute; + inset-inline-start: calc(100% + var(--layout-gutter)); + width: var(--layout-margin-width); + } + } +} diff --git a/src/styles/components/blocks/spoiler.css b/src/styles/components/blocks/spoiler.css new file mode 100644 index 0000000..427983c --- /dev/null +++ b/src/styles/components/blocks/spoiler.css @@ -0,0 +1,35 @@ +@layer components.blocks { + .spoiler__toggle { + position: absolute; + + overflow: hidden; + + width: 1px; + height: 1px; + margin: -1px; + + white-space: nowrap; + + clip-path: inset(50%); + } + + .spoiler__text { + @mixin px var(--space-1); + + cursor: pointer; + user-select: none; + color: transparent; + background-color: var(--color-text-primary); + } + + .spoiler__toggle:checked + .spoiler__text { + cursor: text; + user-select: text; + color: inherit; + background-color: var(--color-surface-elevated-3); + } + + .spoiler__toggle:focus-visible + .spoiler__text { + @mixin focus-ring; + } +} diff --git a/src/styles/components/blocks/table-wrapper.css b/src/styles/components/blocks/table-wrapper.css new file mode 100644 index 0000000..532ade1 --- /dev/null +++ b/src/styles/components/blocks/table-wrapper.css @@ -0,0 +1,73 @@ +@layer components.blocks { + .table-wrapper { + --table-header-fg: var(--color-text-inverse); + --table-header-bg: var(--color-surface-inverse); + --table-header-rule: 0; + --table-header-edge: var(--color-border-normal); + + @mixin my var(--space-10); + + counter-increment: tbl; + + & figcaption { + @mixin typo_label; + @mixin mb var(--size-3); + display: flex; + gap: var(--size-5); + color: var(--color-text-secondary); + + &::before { + content: "Table " counter(tbl, upper-roman) " ⟫"; + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + white-space: nowrap; + } + } + + & table { + @mixin my var(--size-0); + + & thead { + & th { + border-block-end: var(--table-header-rule); + color: var(--table-header-fg); + background-color: var(--table-header-bg); + } + } + + & td { + &.text { + font-family: var(--font-body); + font-size: 1.077em; + line-height: 1.55; + color: var(--color-text-secondary); + text-wrap: pretty; + } + } + } + + & .num { + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; + } + + & .key { + font-weight: var(--font-weight-emphasis); + } + } + + .prose :is(h2, h3, h4) + .table-wrapper, + :is(.callout, .reveal) .table-wrapper { + --table-header-fg: inherit; + --table-header-bg: var(--color-surface-base); + --table-header-rule: var(--border-card) solid var(--color-border-strong); + --table-header-edge: var(--color-border-strong); + } + + @media (--bp-mobile) { + .table-wrapper .table-scroll thead th:first-child { + border-inline-end-color: var(--table-header-edge); + } + } +} diff --git a/src/styles/components/blocks/terms.css b/src/styles/components/blocks/terms.css new file mode 100644 index 0000000..92d162e --- /dev/null +++ b/src/styles/components/blocks/terms.css @@ -0,0 +1,103 @@ +@layer components.blocks { + .terms { + @mixin ma var(--size-0); + + display: grid; + } + + .term { + display: flex; + flex-direction: column; + gap: var(--size-8); + justify-content: space-between; + } + + .term__name { + @mixin typo_heading-3; + hyphens: auto; + overflow-wrap: break-word; + } + + .term__kind { + @mixin typo_label; + + font-size: var(--type--2); + color: var(--color-text-quarternary); + } + + .term__body { + @mixin ma var(--size-0); + } + + .term__definition { + @mixin typo_body; + @mixin ma var(--size-0); + } + + .term__see { + @mixin typo_label; + @mixin mt var(--size-8); + @mixin mb var(--size-0); + @mixin pt var(--size-5); + @mixin border-t var(--border-hairline), solid, var(--color-border-subtle); + + display: flex; + flex-wrap: wrap; + gap: var(--size-5); + + font-size: var(--type--2); + color: var(--color-text-quarternary); + } + + .term__see-label::after { + content: " ⟫"; + } + + .terms .term__link { + color: var(--color-text-primary); + text-decoration: underline; + text-underline-offset: var(--size-2); + + &:hover, + &:focus-visible { + color: var(--color-primary); + } + } + + .terms--cross { + @mixin my var(--space-9); + + grid-template-columns: minmax(0, 11rem) minmax(0, 1fr); + gap: var(--border-card); + border: var(--border-card) solid var(--color-border-strong); + background-color: var(--color-border-strong); + } + + .terms--cross .term { + @mixin py var(--size-8); + @mixin px var(--size-9); + + color: var(--color-text-primary); + background-color: var(--color-surface-base); + + &:not(:first-of-type) { + box-shadow: 0 calc(-1 * var(--border-card)) 0 var(--color-border-normal); + } + } + + .terms--cross .term:target { + color: var(--color-text-on-accent); + background-color: var(--color-primary); + } + + .terms--cross .term:target .term__kind { + color: var(--color-text-on-accent); + } + + .terms--cross .term__body { + @mixin py var(--size-8); + @mixin px var(--size-9); + + background-color: var(--color-surface-elevated-1); + } +} diff --git a/src/styles/components/dev/dev-banner.css b/src/styles/components/dev/dev-banner.css new file mode 100644 index 0000000..b1e0ba1 --- /dev/null +++ b/src/styles/components/dev/dev-banner.css @@ -0,0 +1,122 @@ +@layer components.blocks { + .dev-banner { + @mixin border-t var(--border-boundary), solid, var(--color-tertiary); + + position: fixed; + z-index: 2; + bottom: var(--size-6); + left: var(--size-6); + + overflow-y: auto; + + max-width: min(28rem, 100% - 2 * var(--size-6)); + max-height: 50dvh; + + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + box-shadow: var(--shadow-raised); + } + + .dev-banner__bar { + @mixin typo_eyebrow; + @mixin py var(--size-4); + @mixin px var(--size-6); + + cursor: pointer; + font-weight: var(--font-weight-emphasis); + color: var(--color-tertiary); + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .dev-banner__crumb { + &::before { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .dev-banner__body { + @mixin px var(--size-6); + @mixin pb var(--size-6); + + display: grid; + gap: var(--size-4); + } + + .dev-banner__notice { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + } + + .dev-banner__list { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: grid; + gap: var(--size-2); + list-style: none; + } + + .dev-banner__item { + @mixin typo_code; + + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--size-4); + + &::before { + font-family: var(--font-body); + } + } + + .dev-banner__item--error { + &::before { + content: "✖" / ""; + color: var(--color-state-error); + } + } + + .dev-banner__item--warning { + &::before { + content: "▲" / ""; + color: var(--color-state-warning); + } + } + + .dev-banner__elsewhere { + @mixin pt var(--size-4); + @mixin border-t var(--border-hairline), solid, var(--color-border-subtle); + } + + .dev-banner__toggle { + @mixin typo_eyebrow; + + cursor: pointer; + color: var(--color-text-quiet); + + &:focus-visible { + @mixin focus-ring; + } + } + + .dev-banner__log { + @mixin typo_code; + @mixin mt var(--size-4); + @mixin mb var(--size-0); + + color: var(--color-text-secondary); + white-space: pre-wrap; + } +} diff --git a/src/styles/components/dev/index.css b/src/styles/components/dev/index.css new file mode 100644 index 0000000..ee89123 --- /dev/null +++ b/src/styles/components/dev/index.css @@ -0,0 +1 @@ +@import url("dev-banner.css"); diff --git a/src/styles/components/home/activity-strip.css b/src/styles/components/home/activity-strip.css new file mode 100644 index 0000000..00e179f --- /dev/null +++ b/src/styles/components/home/activity-strip.css @@ -0,0 +1,30 @@ +@layer components.blocks { + .activity-strip { + @mixin ma var(--size-0); + } + + .activity-strip__summary { + @mixin visually-hidden; + } + + .activity-strip__cells { + display: flex; + gap: var(--size-1); + } + + .activity-strip__cell { + aspect-ratio: 1; + width: var(--size-5); + border: var(--border-hairline) solid var(--color-border-normal); + } + + .activity-strip__cell--touched { + border-color: var(--color-surface-inverse); + background-color: var(--color-surface-inverse); + } + + .activity-strip__cell--latest { + border-color: var(--color-primary); + background-color: var(--color-primary); + } +} diff --git a/src/styles/components/home/changelog.css b/src/styles/components/home/changelog.css new file mode 100644 index 0000000..90634a9 --- /dev/null +++ b/src/styles/components/home/changelog.css @@ -0,0 +1,121 @@ +@layer components.blocks { + .changelog { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--border-card); + + border: var(--border-card) solid var(--color-border-strong); + + list-style: none; + + background-color: var(--color-border-strong); + + @media (--bp-tablet-up) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + .changelog__release { + @mixin pa var(--size-8); + + display: flex; + flex-direction: column; + gap: var(--size-6); + background-color: var(--color-surface-elevated-1); + } + + .changelog__head { + display: flex; + gap: var(--size-4); + align-items: baseline; + justify-content: space-between; + } + + .changelog__version { + @mixin ma var(--size-0); + + font-family: var(--font-header); + font-size: var(--type-6); + font-weight: var(--font-weight-heading); + line-height: 1; + } + + .changelog__date { + @mixin typo_tag; + + &::before { + content: "⟫" / ""; + margin-inline-end: var(--size-2); + font-family: var(--font-body); + } + } + + .changelog__changes { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: grid; + gap: var(--size-6); + list-style: none; + } + + .changelog__change { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--size-2) var(--size-4); + align-items: center; + } + + .changelog__kind { + @mixin typo_tag; + @mixin py var(--size-1); + @mixin px var(--size-3); + + justify-self: start; + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + } + + .changelog__work { + @mixin typo_eyebrow; + + overflow: hidden; + + color: var(--color-text-quiet); + text-align: end; + text-overflow: ellipsis; + white-space: nowrap; + } + + .changelog__text { + @mixin typo_body-small; + @mixin ma var(--size-0); + + grid-column: 1 / -1; + + & a { + text-decoration: underline; + text-decoration-color: var(--color-border-normal); + text-underline-offset: 0.2em; + + &:hover { + text-decoration-color: var(--color-primary); + } + + &:focus-visible { + @mixin focus-ring; + } + } + } + + .changelog__more { + @mixin typo_eyebrow; + @mixin mt auto; + @mixin mb var(--size-0); + + color: var(--color-text-quiet); + } +} diff --git a/src/styles/components/home/home-hero.css b/src/styles/components/home/home-hero.css new file mode 100644 index 0000000..ea04e73 --- /dev/null +++ b/src/styles/components/home/home-hero.css @@ -0,0 +1,114 @@ +@layer components.blocks { + .home-hero { + color: var(--color-text-primary); + background-color: var(--color-surface-base); + } + + .home-hero__inner { + @mixin layout-wrapper; + @mixin py var(--size-12); + + display: grid; + gap: var(--size-10); + } + + .home-hero__eyebrow { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + @mixin pb var(--size-4); + @mixin border-b var(--border-hairline), solid, var(--color-border-subtle); + + color: var(--color-text-quiet); + } + + .home-hero__title { + @mixin ma var(--size-0); + + font-family: var(--font-display); + font-size: var(--type-slab); + font-weight: var(--font-weight-display); + line-height: 0.9; + text-shadow: var(--size-2) var(--size-2) 0 var(--color-primary); + letter-spacing: var(--tracking-display); + } + + .home-hero__stats { + @mixin ma var(--size-0); + + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--border-hairline); + + border: var(--border-hairline) solid var(--color-border-subtle); + + background-color: var(--color-border-subtle); + + @media (--bp-tablet-up) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + .home-hero__stat { + @mixin pa var(--size-8); + + display: flex; + flex-direction: column; + gap: var(--size-3); + + min-height: var(--size-16); + + background-color: var(--color-surface-elevated-1); + + & dd { + @mixin ma var(--size-0); + } + } + + .home-hero__stat--about { + grid-column: 1 / -1; + color: var(--color-text-on-accent); + background-color: var(--color-primary); + + @media (--bp-tablet-up) { + grid-column: auto; + } + } + + .home-hero__label { + @mixin typo_eyebrow; + + color: var(--color-text-secondary); + + .home-hero__stat--about & { + color: inherit; + } + } + + .home-hero__index { + &::after { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .home-hero__tagline { + @mixin typo_heading-3; + @mixin mt auto; + } + + .home-hero__value { + @mixin mt auto; + + font-family: var(--font-header); + font-size: var(--type-7); + font-weight: var(--font-weight-heading); + line-height: 1; + } + + .home-hero__note { + @mixin typo_eyebrow; + + color: var(--color-text-quiet); + } +} diff --git a/src/styles/components/home/home-section.css b/src/styles/components/home/home-section.css new file mode 100644 index 0000000..d937221 --- /dev/null +++ b/src/styles/components/home/home-section.css @@ -0,0 +1,25 @@ +@layer components.blocks { + .home-section { + @mixin layout-wrapper; + @mixin my var(--size-14); + } + + .home-section__heading { + @mixin typo_heading-2; + @mixin mt var(--size-0); + @mixin mb var(--size-8); + @mixin pt var(--size-6); + @mixin border-t var(--border-boundary), solid, var(--color-border-strong); + + display: flex; + gap: var(--size-6); + align-items: baseline; + } + + .home-section__number { + @mixin typo_eyebrow; + + font-weight: var(--font-weight-emphasis); + color: var(--color-text-secondary); + } +} diff --git a/src/styles/components/home/index.css b/src/styles/components/home/index.css new file mode 100644 index 0000000..853879a --- /dev/null +++ b/src/styles/components/home/index.css @@ -0,0 +1,5 @@ +@import url("home-hero.css"); +@import url("home-section.css"); +@import url("changelog.css"); +@import url("work-list.css"); +@import url("activity-strip.css"); diff --git a/src/styles/components/home/work-list.css b/src/styles/components/home/work-list.css new file mode 100644 index 0000000..5e1583e --- /dev/null +++ b/src/styles/components/home/work-list.css @@ -0,0 +1,119 @@ +@layer components.blocks { + .work-list { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + list-style: none; + } + + .work-list__item { + @mixin py var(--size-8); + @mixin border-b var(--border-hairline), solid, var(--color-border-strong); + + display: grid; + grid-template-areas: + "numeral main" + "numeral meta"; + grid-template-columns: var(--size-14) minmax(0, 1fr); + gap: var(--size-6) var(--size-10); + align-items: center; + + @media (--bp-tablet-up) { + grid-template-areas: "numeral main meta"; + grid-template-columns: var(--size-16) minmax(0, 1fr) auto; + } + } + + .work-list__numeral { + grid-area: numeral; + align-self: start; + + font-family: var(--font-header); + font-size: var(--type-9); + font-weight: var(--font-weight-heading); + line-height: 1; + } + + .work-list__main { + grid-area: main; + min-width: 0; + } + + .work-list__title { + @mixin ma var(--size-0); + + font-family: var(--font-header); + font-size: clamp(var(--type-5), 3vw, var(--type-8)); + font-weight: var(--font-weight-heading); + line-height: 1; + text-transform: uppercase; + letter-spacing: var(--tracking-8); + + & a { + &:hover { + text-decoration: underline; + text-decoration-color: var(--color-primary); + text-decoration-thickness: var(--border-card); + } + + &:focus-visible { + @mixin focus-ring; + } + } + } + + .work-list__excerpt { + @mixin typo_body-small; + @mixin mt var(--size-3); + @mixin mb var(--size-0); + + color: var(--color-text-secondary); + } + + .work-list__meta { + display: flex; + grid-area: meta; + flex-direction: column; + gap: var(--size-4); + align-items: flex-start; + + @media (--bp-tablet-up) { + align-items: flex-end; + } + } + + .work-list__sections { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: flex; + flex-wrap: wrap; + gap: var(--size-2) var(--size-3); + + color: var(--color-text-secondary); + list-style: none; + + & li + li { + &::before { + content: "·" / ""; + margin-inline-end: var(--size-3); + } + } + + & a { + &:hover { + color: var(--color-text-primary); + text-decoration: underline; + } + + &:focus-visible { + @mixin focus-ring; + } + } + + @media (--bp-tablet-up) { + justify-content: flex-end; + } + } +} diff --git a/src/styles/components/index.css b/src/styles/components/index.css new file mode 100644 index 0000000..543409f --- /dev/null +++ b/src/styles/components/index.css @@ -0,0 +1,6 @@ +@import url("prose/index.css"); +@import url("blocks/index.css"); +@import url("post/index.css"); +@import url("site/index.css"); +@import url("home/index.css"); +@import url("dev/index.css"); diff --git a/src/styles/components/post/article-card-grid.css b/src/styles/components/post/article-card-grid.css new file mode 100644 index 0000000..88838aa --- /dev/null +++ b/src/styles/components/post/article-card-grid.css @@ -0,0 +1,34 @@ +@layer components.blocks { + .article-card-grid { + @mixin layout-wrapper; + @mixin my var(--size-12); + } + + .article-card-grid__title { + @mixin typo_heading-2; + @mixin mt var(--size-0); + @mixin mb var(--size-8); + } + + .article-card-grid__list { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--size-10); + list-style: none; + + @media (--bp-tablet-up) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (--bp-desktop) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + .article-card-grid__item { + display: grid; + } +} diff --git a/src/styles/components/post/article-card.css b/src/styles/components/post/article-card.css new file mode 100644 index 0000000..c49805a --- /dev/null +++ b/src/styles/components/post/article-card.css @@ -0,0 +1,84 @@ +@layer components.blocks { + .article-card { + position: relative; + + display: flex; + flex-direction: column; + + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-surface-elevated-1); + box-shadow: var(--shadow-card); + + transition: box-shadow var(--motion-fast) var(--ease-out); + + &:hover { + box-shadow: var(--shadow-raised); + } + + &:has(.article-card__link:focus-visible) { + @mixin focus-ring; + } + } + + .article-card__cover { + @mixin border-b var(--border-card), solid, var(--color-border-strong); + + & img { + display: block; + + aspect-ratio: 16 / 9; + width: 100%; + height: auto; + + object-fit: cover; + } + } + + .article-card__body { + @mixin pa var(--size-9); + + display: flex; + flex: 1; + flex-direction: column; + gap: var(--size-6); + } + + .article-card__title { + @mixin typo_heading-2; + @mixin ma var(--size-0); + } + + .article-card__link { + &::after { + content: ""; + position: absolute; + inset: 0; + } + + &:focus-visible { + outline: none; + } + } + + .article-card__excerpt { + @mixin typo_body-small; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + } + + .article-card__date { + @mixin typo_tag; + @mixin mt auto; + @mixin pt var(--size-5); + @mixin border-t var(--border-hairline), solid, var(--color-border-subtle); + + &::before { + content: "❯" / ""; + margin-inline-end: var(--size-3); + font-family: var(--font-body); + color: var(--color-text-quiet); + } + } +} diff --git a/src/styles/components/post/article-footer.css b/src/styles/components/post/article-footer.css new file mode 100644 index 0000000..2dbc2b2 --- /dev/null +++ b/src/styles/components/post/article-footer.css @@ -0,0 +1,21 @@ +@layer components.blocks { + .article-footer { + @mixin layout-panel; + @mixin my var(--size-12); + + display: grid; + gap: var(--size-10); + + @media (--bp-desktop) { + @mixin layout-columns; + + & > .revision-card { + grid-column: body-start / body-end; + } + + & > .sequence-nav { + grid-column: rail-start / margin-end; + } + } + } +} diff --git a/src/styles/components/post/article-header.css b/src/styles/components/post/article-header.css new file mode 100644 index 0000000..ea70cc8 --- /dev/null +++ b/src/styles/components/post/article-header.css @@ -0,0 +1,158 @@ +@layer components.blocks { + .article-header__panel { + @mixin mt var(--size-10); + @mixin layout-panel; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--border-card); + + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-border-strong); + + @media (--bp-desktop) { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + } + + .article-header__designation { + @mixin pa var(--size-9); + display: flex; + flex-direction: column; + gap: var(--size-10); + background-color: var(--color-surface-elevated-1); + } + + .article-header__label { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + color: var(--color-text-secondary); + } + + .article-header__index { + &::after { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .article-header__title { + @mixin mt auto; + @mixin mb var(--size-0); + + font-family: var(--font-display); + font-size: clamp(var(--type-9), 5vw, var(--size-14)); + font-weight: var(--font-weight-display); + line-height: 1; + color: var(--color-text-primary); + letter-spacing: var(--tracking-display); + } + + .article-header__subtitle { + @mixin typo_standfirst; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + } + + .article-header__meta { + @mixin ma var(--size-0); + + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--border-card); + } + + .article-header__cell { + @mixin pa var(--size-9); + + display: flex; + flex-direction: column; + gap: var(--size-8); + background-color: var(--color-surface-elevated-1); + + & dd { + @mixin ma var(--size-0); + } + } + + .article-header__value { + font-family: var(--font-mono); + font-size: var(--type-0); + line-height: var(--leading-0); + color: var(--color-text-primary); + text-transform: uppercase; + + & time { + font-weight: var(--font-weight-emphasis); + } + } + + .article-header__tags { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: flex; + flex-direction: column; + gap: var(--size-2); + align-items: flex-start; + + list-style: none; + } + + .article-header__tag { + @mixin py var(--size-1); + @mixin px var(--size-3); + @mixin typo_tag; + display: block; + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + transition: background-color var(--motion-fast) var(--ease-out); + + &:hover { + color: var(--color-text-on-accent); + background-color: var(--color-primary); + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .article-header__cover { + @mixin ma var(--size-0); + + grid-column: 1 / -1; + + & img { + display: block; + + aspect-ratio: var(--figure-ratio, auto); + width: 100%; + height: auto; + + object-fit: cover; + } + } + + .article-header__caption { + @mixin typo_code; + @mixin py var(--size-4); + @mixin px var(--size-9); + + display: flex; + flex-wrap: wrap; + gap: var(--size-2) var(--size-6); + justify-content: center; + + color: var(--color-text-primary); + text-align: center; + + background-color: var(--color-surface-base); + } + + .article-header__credit { + color: var(--color-text-secondary); + } +} diff --git a/src/styles/components/post/index.css b/src/styles/components/post/index.css new file mode 100644 index 0000000..7bb7148 --- /dev/null +++ b/src/styles/components/post/index.css @@ -0,0 +1,8 @@ +@import url("article-card.css"); +@import url("article-card-grid.css"); +@import url("article-header.css"); +@import url("article-footer.css"); +@import url("revision-card.css"); +@import url("sequence-nav.css"); +@import url("simple-header.css"); +@import url("stage-badge.css"); diff --git a/src/styles/components/post/revision-card.css b/src/styles/components/post/revision-card.css new file mode 100644 index 0000000..65a1b1e --- /dev/null +++ b/src/styles/components/post/revision-card.css @@ -0,0 +1,92 @@ +@layer components.blocks { + .revision-card { + @mixin border-t var(--border-boundary), solid, var(--color-border-strong); + @mixin pt var(--size-8); + } + + .revision-card__head { + @mixin mb var(--size-6); + + display: flex; + flex-wrap: wrap; + gap: var(--size-2) var(--size-6); + align-items: baseline; + justify-content: space-between; + } + + .revision-card__heading { + @mixin typo_heading-2; + @mixin ma var(--size-0); + } + + .revision-card__subject { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + } + + .revision-card__list { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + list-style: none; + + @media (--bp-tablet-up) { + display: grid; + grid-template-columns: auto auto auto minmax(0, 1fr); + column-gap: var(--size-10); + } + } + + .revision-card__row { + @mixin border-b var(--border-hairline), solid, var(--color-border-subtle); + @mixin py var(--size-6); + + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: var(--size-2) var(--size-8); + align-items: baseline; + + font-family: var(--font-mono); + font-size: var(--type-0); + line-height: var(--leading-0); + + @media (--bp-tablet-up) { + grid-column: 1 / -1; + grid-template-columns: subgrid; + } + } + + .revision-card__version { + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + } + + .revision-card__date { + color: var(--color-text-quiet); + } + + .revision-card__kind { + @mixin typo_tag; + @mixin py var(--size-1); + @mixin px var(--size-3); + + justify-self: start; + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + } + + .revision-card__text { + @mixin ma var(--size-0); + + grid-column: 1 / -1; + font-family: var(--font-body); + line-height: var(--leading-prose-tight); + color: var(--color-text-primary); + + @media (--bp-tablet-up) { + grid-column: auto; + } + } +} diff --git a/src/styles/components/post/sequence-nav.css b/src/styles/components/post/sequence-nav.css new file mode 100644 index 0000000..f51ddd8 --- /dev/null +++ b/src/styles/components/post/sequence-nav.css @@ -0,0 +1,188 @@ +@layer components.blocks { + .sequence-nav { + display: grid; + grid-template-areas: + "overview" + "previous" + "next"; + grid-template-columns: minmax(0, 1fr); + gap: var(--border-card); + + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-border-strong); + + @media (--bp-tablet-up) { + grid-template-areas: "previous overview next"; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + } + + .sequence-nav__step { + background-color: var(--color-surface-elevated-1); + + &:empty { + @media (--bp-mobile) { + display: none; + } + } + } + + .sequence-nav__step--previous { + grid-area: previous; + } + + .sequence-nav__step--next { + grid-area: next; + text-align: end; + } + + .sequence-nav__overview { + @mixin pa var(--size-9); + + display: flex; + grid-area: overview; + flex-direction: column; + gap: var(--size-6); + + background-color: var(--color-surface-elevated-1); + } + + .sequence-nav__link { + @mixin pa var(--size-9); + + display: flex; + flex-direction: column; + gap: var(--size-6); + + height: 100%; + + color: var(--color-text-primary); + + transition: background-color var(--motion-fast) var(--ease-out); + + &:hover { + background-color: var(--color-surface-hover); + } + + &:focus-visible { + @mixin focus-ring 2px var(--color-focus-ring) -4px; + } + } + + .sequence-nav__label { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + + .sequence-nav__overview & { + color: var(--color-text-primary); + } + + .sequence-nav__step--previous & { + &::before { + content: "⟪" / ""; + margin-inline-end: var(--size-2); + font-family: var(--font-body); + } + } + + .sequence-nav__step--next & { + &::after { + content: "⟫" / ""; + margin-inline-start: var(--size-2); + font-family: var(--font-body); + } + } + } + + .sequence-nav__numeral { + .sequence-nav__step--previous & { + &::before { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .sequence-nav__step--next & { + &::after { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + } + + .sequence-nav__count { + &::before { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .sequence-nav__parent { + &:hover { + text-decoration: underline; + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .sequence-nav__title { + @mixin typo_heading-2; + } + + .sequence-nav__squares { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: flex; + flex-wrap: wrap; + gap: var(--size-2); + list-style: none; + } + + .sequence-nav__square { + @mixin px var(--size-1); + + display: grid; + place-items: center; + + min-width: var(--size-10); + height: var(--size-10); + border: var(--border-hairline) solid var(--color-border-normal); + + font-family: var(--font-mono); + font-size: var(--type--3); + line-height: 1; + color: var(--color-text-secondary); + + background-color: var(--color-surface-elevated-1); + + &:visited { + border-color: var(--color-surface-inverse); + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + } + + &:hover { + border-color: var(--color-border-strong); + } + + &[aria-current="page"] { + border-color: var(--color-primary); + font-weight: var(--font-weight-emphasis); + color: var(--color-text-on-accent); + background-color: var(--color-primary); + } + + &:focus-visible { + @mixin focus-ring; + } + } +} diff --git a/src/styles/components/post/simple-header.css b/src/styles/components/post/simple-header.css new file mode 100644 index 0000000..2ba3050 --- /dev/null +++ b/src/styles/components/post/simple-header.css @@ -0,0 +1,87 @@ +@layer components.blocks { + .simple-header { + @mixin border-t var(--border-hairline), solid, var(--color-border-subtle); + @mixin border-b var(--border-hairline), solid, var(--color-border-normal); + + color: var(--color-text-primary); + background-color: var(--color-surface-base); + } + + .simple-header__inner { + @mixin layout-wrapper; + @mixin py var(--size-14); + + display: grid; + gap: var(--size-10); + + @media (--bp-desktop) { + @mixin layout-columns; + + align-items: center; + } + } + + .simple-header__text { + @media (--bp-desktop) { + grid-column: body-start / margin-end; + grid-row: 1; + } + } + + .simple-header__eyebrow { + @mixin typo_label; + @mixin ma var(--size-0); + + color: var(--color-text-quiet); + } + + .simple-header__title { + @mixin mt var(--size-6); + @mixin mb var(--size-0); + + font-family: var(--font-display); + font-size: clamp(var(--type-9), 6vw, var(--size-15)); + font-weight: var(--font-weight-display); + line-height: 1; + letter-spacing: var(--tracking-display); + } + + .simple-header__standfirst { + @mixin typo_standfirst; + @mixin mt var(--size-8); + @mixin mb var(--size-0); + + color: var(--color-text-secondary); + } + + .simple-header__count { + @mixin ma var(--size-0); + + display: grid; + place-items: center; + order: -1; + + aspect-ratio: 1; + width: var(--size-14); + + font-family: var(--font-header); + font-size: var(--type-7); + font-weight: var(--font-weight-heading); + line-height: 1; + color: var(--color-text-on-accent); + + background-color: var(--color-primary); + box-shadow: var(--shadow-raised); + + @media (--bp-desktop) { + grid-column: rail-start / rail-end; + grid-row: 1; + width: var(--size-16); + font-size: var(--type-10); + } + } + + .simple-header__hidden { + @mixin visually-hidden; + } +} diff --git a/src/styles/components/post/stage-badge.css b/src/styles/components/post/stage-badge.css new file mode 100644 index 0000000..2ca8f0e --- /dev/null +++ b/src/styles/components/post/stage-badge.css @@ -0,0 +1,52 @@ +@layer components.blocks { + .stage-badge { + display: inline-flex; + gap: var(--size-3); + align-items: baseline; + + font-family: var(--font-mono); + font-size: var(--type--1); + line-height: 1; + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: var(--tracking-tag); + } + + .stage-badge--stub, + .stage-badge--developing { + @mixin py var(--size-2); + @mixin px var(--size-3); + + color: var(--color-text-on-accent); + background-color: var(--color-tertiary); + + &::before { + content: ""; + width: 1cap; + height: 1cap; + background-color: currentcolor; + } + } + + .stage-badge--stub { + &::before { + clip-path: polygon( + evenodd, + 50% 0, + 100% 100%, + 0 100%, + 50% 0, + 50% 36%, + 21% 86%, + 79% 86%, + 50% 36% + ); + } + } + + .stage-badge--developing { + &::before { + clip-path: polygon(50% 0, 100% 100%, 0 100%); + } + } +} diff --git a/src/styles/components/prose/index.css b/src/styles/components/prose/index.css new file mode 100644 index 0000000..720bcd1 --- /dev/null +++ b/src/styles/components/prose/index.css @@ -0,0 +1 @@ +@import url("prose.css"); diff --git a/src/styles/components/prose/prose.css b/src/styles/components/prose/prose.css new file mode 100644 index 0000000..6631ba4 --- /dev/null +++ b/src/styles/components/prose/prose.css @@ -0,0 +1,339 @@ +@property --prose-line { + inherits: true; + initial-value: 0; + syntax: ""; +} + +@layer components.prose { + .prose { + @mixin typo_body; + + counter-reset: sec sub tbl fig box; + + & > :first-child { + margin-block-start: var(--size-0); + } + + & > :last-child { + margin-block-end: var(--size-0); + } + + & ::selection { + color: var(--color-text-on-accent); + background-color: var(--color-primary); + } + + /* === Paragraphs === */ + & p { + @mixin my var(--space-6); + text-wrap: pretty; + } + + /* === HEADINGS === */ + & h2 { + @mixin typo_heading-2; + @mixin mt var(--space-11); + @mixin mb var(--space-8); + @mixin ps var(--size-9); + counter-set: sub 0 fig 0 box 0; + counter-increment: sec; + + clear: both; + display: flex; + flex-direction: column; + gap: var(--space-4); + + border-inline-start: var(--slot-prose-bar) solid var(--color-text-primary); + + text-wrap: balance; + + &::before { + @mixin pt var(--space-4); + content: "§ " counter(sec, upper-roman); + + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: var(--font-weight-emphasis); + line-height: 1; + letter-spacing: var(--tracking-label); + } + } + + & h3 { + @mixin typo_heading-3; + @mixin mt var(--space-10); + @mixin mb var(--space-7); + @mixin py var(--size-3); + @mixin px var(--size-7); + counter-increment: sub; + + display: flex; + gap: var(--size-7); + align-items: baseline; + + color: var(--color-text-inverse); + + background-color: var(--color-surface-inverse); + + &::before { + content: counter(sec, upper-roman) "." counter(sub); + + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: var(--font-weight-emphasis); + color: var(--color-primary); + } + } + + & h4 { + @mixin typo_heading-4; + @mixin mt var(--space-9); + @mixin mb var(--space-6); + @mixin py var(--size-2); + border-block: var(--border-shell) solid var(--color-text-primary); + } + + /* === LISTS === */ + + & ul, + & ol { + @mixin my var(--space-6); + @mixin pl var(--size-0); + + display: flex; + flex-direction: column; + gap: var(--space-4); + list-style: none; + + & li { + --prose-line: 1lh; + display: grid; + grid-template-columns: var(--slot-prose-marker) minmax(0, 1fr); + column-gap: var(--slot-prose-gap); + + & > * { + grid-column: 2; + } + + & > ul, + & > ol { + @mixin mt var(--space-3); + @mixin mb var(--size-0); + gap: var(--space-3); + } + } + } + + & ul { + & > li { + &::before { + content: ""; + + justify-self: center; + + width: 0.625rem; + height: 0.625rem; + margin-block-start: calc((var(--prose-line) - 0.625rem) / 2); + + background-color: var(--color-text-primary); + } + + & ul { + & > li { + &::before { + background-color: transparent; + box-shadow: inset 0 0 0 var(--border-card) var(--color-text-primary); + } + } + + & ul { + & > li { + &::before { + height: var(--size-2); + margin-block-start: calc((var(--prose-line) - var(--size-2)) / 2); + background-color: var(--color-text-primary); + box-shadow: none; + } + } + } + } + } + } + + & ol { + counter-reset: step; + + & > li { + counter-increment: step; + + &::before { + content: counter(step, decimal-leading-zero); + + display: grid; + place-items: center; + + height: 1.375rem; + margin-block-start: calc((var(--prose-line) - 1.375rem) / 2); + + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: var(--font-weight-emphasis); + line-height: 1; + color: var(--color-text-inverse); + + background-color: var(--color-surface-inverse); + } + + & ol { + & > li { + &::before { + content: counter(step, lower-alpha); + color: var(--color-text-primary); + background-color: transparent; + box-shadow: inset 0 0 0 var(--border-card) var(--color-text-primary); + } + } + } + } + } + + /* === QUOTES & RULES === */ + & blockquote { + @mixin my var(--space-6); + @mixin ps 3ch; + font-style: italic; + color: var(--color-text-secondary); + } + + & hr { + @mixin my var(--space-10); + @mixin border-t var(--border-hairline), solid, var(--color-border-strong); + border-block-end: none; + border-inline: none; + } + + /* === INLINE === */ + & :not(pre) { + & > code { + @mixin px var(--space-2); + @mixin py var(--space-px); + font-family: var(--font-mono); + font-size: 0.875em; + background-color: var(--color-surface-elevated-3); + } + } + + & sup { + font-family: var(--font-mono); + font-size: 0.7em; + } + + & pre { + @mixin typo_code; + @mixin my var(--space-9); + @mixin py var(--size-7); + @mixin px var(--size-8); + overflow: auto; + border: var(--border-card) solid var(--color-border-strong); + tab-size: 2; + background-color: var(--color-surface-elevated-1); + + & code { + @mixin pa var (--size-0); + font: inherit; + background-color: transparent; + } + } + + & a { + color: var(--color-text-link); + text-decoration: underline; + text-decoration-thickness: var(--size-1); + text-underline-offset: var(--size-2); + + transition: color var(--motion-fast) var(--ease-out); + + &:visited { + color: var(--color-text-link-visited); + } + + &:hover { + color: var(--color-text-link-hover); + } + + &:focus-visible { + @mixin focus-ring; + } + } + + & em { + font-style: italic; + } + + & b, + & strong { + font-weight: var(--font-weight-emphasis); + } + + & mark { + @mixin px var(--size-1); + color: var(--color-text-on-accent); + background-color: var(--color-tertiary); + } + + & del, + & s { + color: var(--color-text-tertiary); + text-decoration: line-through; + } + + /* === TABLES === */ + table { + @mixin typo_table-data; + @mixin my var(--space-10); + border-spacing: 0; + border-collapse: separate; + width: 100%; + + & th, + & td { + @mixin py var(--space-4); + @mixin px var(--space-6); + @mixin border-b var(--border-hairline), solid, var(--color-border-subtle); + } + + & thead { + & th { + @mixin typo_table-header; + @mixin border-b var(--border-card), solid, var(--color-border-strong); + } + } + + & tbody { + & tr { + &:last-child { + & :is(th, td) { + @mixin border-b var(--border-card), solid, var(--color-border-strong); + } + } + + &:hover { + background-color: var(--color-surface-hover); + } + } + + & th[scope="row"] { + font-family: var(--font-header); + font-weight: var(--font-weight-heading); + text-transform: uppercase; + } + } + } + + & :is(tfoot, tr.total) { + & :is(th, td) { + @mixin border-t var(--border-card), solid, var(--color-border-strong); + } + } + } +} diff --git a/src/styles/components/site/finder.css b/src/styles/components/site/finder.css new file mode 100644 index 0000000..9a1a35f --- /dev/null +++ b/src/styles/components/site/finder.css @@ -0,0 +1,318 @@ +@layer components.blocks { + .finder { + display: block; + flex: 0 1 22rem; + min-width: 0; + + & kbd { + @mixin typo_tag; + @mixin py var(--size-1); + @mixin px var(--size-3); + + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + } + } + + /* === TRIGGER === */ + + .finder__trigger { + @mixin py var(--size-3); + @mixin ps var(--size-5); + @mixin pe var(--size-3); + + cursor: pointer; + + display: flex; + gap: var(--size-5); + align-items: center; + + width: 100%; + border: var(--border-hairline) solid var(--color-border-subtle); + + font-family: var(--font-mono); + font-size: var(--type-0); + line-height: 1; + color: var(--color-text-quiet); + + background-color: var(--color-surface-elevated-1); + + transition: + color var(--motion-fast) var(--ease-out), + border-color var(--motion-fast) var(--ease-out); + + &:hover { + border-color: var(--color-border-normal); + color: var(--color-text-secondary); + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .finder__prompt { + font-family: var(--font-body); + color: var(--color-primary); + } + + .finder__placeholder { + flex: 1; + text-align: start; + } + + .finder__key { + @media (--bp-mobile) { + display: none; + } + } + + /* === DIALOG === */ + + .finder__dialog { + @mixin pa var(--size-0); + + width: min(40rem, 100% - 2 * var(--layout-page-margin)); + max-width: none; + max-height: min(40rem, 76dvh); + margin: 12dvh auto auto; + border: var(--border-card) solid var(--color-border-strong); + + color: var(--color-text-primary); + + background-color: var(--color-surface-elevated-1); + box-shadow: var(--size-4) var(--size-4) 0 var(--color-primary); + + &::backdrop { + background-color: var(--color-overlay-heavy); + } + + &[open] { + display: flex; + flex-direction: column; + } + } + + .finder__bar { + @mixin py var(--size-4); + @mixin px var(--size-8); + + display: flex; + gap: var(--size-6); + align-items: center; + justify-content: space-between; + + color: var(--color-text-inverse); + + background-color: var(--color-surface-inverse); + } + + .finder__title { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + + font-weight: var(--font-weight-emphasis); + } + + .finder__crumb { + &::before { + content: "⟫" / ""; + margin-inline: var(--size-2); + font-family: var(--font-body); + } + } + + .finder__close { + @mixin typo_tag; + @mixin py var(--size-1); + @mixin px var(--size-3); + + cursor: pointer; + border: var(--border-hairline) solid currentcolor; + + &:focus-visible { + @mixin focus-ring; + } + } + + .finder__field { + @mixin px var(--size-8); + @mixin border-b var(--border-card), solid, var(--color-border-strong); + + display: flex; + gap: var(--size-6); + align-items: center; + + & .finder__prompt { + font-size: var(--type-3); + } + } + + .finder__input { + @mixin py var(--size-8); + + flex: 1; + + min-width: 0; + + font-family: var(--font-mono); + font-size: var(--type-3); + color: var(--color-text-primary); + + background: transparent; + outline: none; + + &::placeholder { + color: var(--color-text-quiet); + } + } + + .finder__status { + @mixin py var(--size-5); + @mixin px var(--size-8); + @mixin border-b var(--border-hairline), solid, var(--color-border-normal); + + display: flex; + gap: var(--size-6); + justify-content: space-between; + } + + .finder__scope, + .finder__count { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + + color: var(--color-text-secondary); + } + + /* === RESULTS === */ + + .finder__results { + @mixin px var(--size-8); + @mixin pb var(--size-6); + + overflow-y: auto; + overscroll-behavior: contain; + flex: 1; + min-height: 0; + } + + .finder__heading { + @mixin typo_eyebrow; + @mixin mt var(--size-8); + @mixin mb var(--size-0); + @mixin pb var(--size-3); + @mixin border-b var(--border-card), solid, var(--color-border-strong); + + display: flex; + gap: var(--size-6); + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + } + + .finder__section { + font-weight: var(--font-weight-body); + color: var(--color-text-secondary); + } + + .finder__row { + @mixin mx calc(-1 * var(--size-8)); + @mixin py var(--size-6); + @mixin px var(--size-8); + + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--size-8); + align-items: center; + + color: var(--color-text-primary); + + &::after { + content: "❯" / ""; + font-family: var(--font-body); + color: var(--color-text-quiet); + } + + &[aria-selected="true"] { + color: var(--color-text-inverse); + background-color: var(--color-surface-inverse); + + &::after { + color: var(--color-primary); + } + + & .finder__type, + & .finder__path { + color: var(--color-text-disabled); + } + } + + @media (--bp-tablet-up) { + grid-template-columns: 6rem minmax(0, 1fr) minmax(0, 14rem) auto; + } + } + + .finder__type { + @mixin typo_eyebrow; + + color: var(--color-text-quiet); + } + + .finder__label { + @mixin typo_heading-3; + } + + .finder__path { + overflow: hidden; + display: none; + + font-family: var(--font-mono); + font-size: var(--type--1); + color: var(--color-text-quiet); + text-align: end; + text-overflow: ellipsis; + white-space: nowrap; + + @media (--bp-tablet-up) { + display: block; + } + } + + .finder__empty { + @mixin typo_eyebrow; + @mixin ma var(--size-0); + @mixin py var(--size-12); + @mixin px var(--size-8); + + color: var(--color-text-quiet); + text-align: center; + } + + /* === FOOTER === */ + + .finder__footer { + @mixin typo_eyebrow; + @mixin py var(--size-5); + @mixin px var(--size-8); + @mixin border-t var(--border-card), solid, var(--color-border-strong); + + display: flex; + gap: var(--size-8); + align-items: center; + color: var(--color-text-primary); + + @media (--bp-mobile) { + display: none; + } + } + + .finder__hint { + display: inline-flex; + gap: var(--size-2); + align-items: center; + + &:last-child { + margin-inline-start: auto; + } + } +} diff --git a/src/styles/components/site/index.css b/src/styles/components/site/index.css new file mode 100644 index 0000000..2c4d309 --- /dev/null +++ b/src/styles/components/site/index.css @@ -0,0 +1,3 @@ +@import url("site-header.css"); +@import url("finder.css"); +@import url("site-footer.css"); diff --git a/src/styles/components/site/site-footer.css b/src/styles/components/site/site-footer.css new file mode 100644 index 0000000..3d2162e --- /dev/null +++ b/src/styles/components/site/site-footer.css @@ -0,0 +1,72 @@ +@layer components.blocks { + .site-footer { + @mixin mt var(--size-16); + + color: var(--color-text-secondary); + background-color: var(--color-surface-base); + } + + .site-footer__inner { + @mixin layout-wrapper; + @mixin typo_eyebrow; + @mixin py var(--size-8); + + display: flex; + flex-wrap: wrap; + gap: var(--size-6) var(--layout-gutter); + align-items: center; + justify-content: space-between; + } + + .site-footer__copyright, + .site-footer__release { + @mixin ma var(--size-0); + } + + .site-footer__version { + font-weight: var(--font-weight-emphasis); + color: var(--color-text-primary); + + &::after { + content: "·" / ""; + margin-inline: var(--size-3); + font-weight: var(--font-weight-body); + } + } + + .site-footer__links { + @mixin ma var(--size-0); + @mixin pa var(--size-0); + + display: flex; + flex-wrap: wrap; + gap: var(--size-2) var(--size-3); + list-style: none; + + & li + li { + &::before { + content: "·" / ""; + margin-inline-end: var(--size-3); + } + } + } + + .site-footer__link { + color: var(--color-text-primary); + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + @mixin focus-ring; + } + } + + .site-footer__top { + &::before { + content: "↑" / ""; + margin-inline-end: var(--size-2); + } + } +} diff --git a/src/styles/components/site/site-header.css b/src/styles/components/site/site-header.css new file mode 100644 index 0000000..b0ccdb2 --- /dev/null +++ b/src/styles/components/site/site-header.css @@ -0,0 +1,111 @@ +@layer components.blocks { + html { + scroll-padding-block-start: calc(var(--layout-header-height) + var(--size-8)); + } + + .site-header { + position: sticky; + z-index: 1; + top: 0; + + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + } + + .site-header__inner { + @mixin layout-wrapper; + + display: flex; + gap: var(--layout-gutter); + align-items: center; + justify-content: space-between; + + height: var(--layout-header-height); + } + + .site-header__home { + display: inline-flex; + gap: var(--size-4); + align-items: center; + + font-family: var(--font-mono); + font-size: var(--type-1); + line-height: var(--leading-1); + + &:focus-visible { + @mixin focus-ring; + } + } + + .site-header__logo { + display: inline-block; + font-family: var(--font-body); + + @media (prefers-reduced-motion: no-preference) { + animation: + site-header-pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite, + site-header-glitch 13s step-end infinite; + } + } + + .site-header__bracket { + color: var(--color-secondary); + } + + @keyframes site-header-pulse { + 0%, + 100% { + opacity: 1; + } + + 25%, + 75% { + opacity: 0.66; + } + + 50% { + opacity: 0.33; + } + } + + @keyframes site-header-glitch { + 0%, + 84%, + 91%, + 100% { + transform: translate(0, 0); + filter: brightness(1); + } + + 85% { + transform: translate(-2px, 0); + filter: brightness(0.4); + } + + 86% { + transform: translate(1px, -1px); + filter: brightness(0.2); + } + + 87% { + transform: translate(-1px, 1px); + filter: brightness(0.7); + } + + 88% { + transform: translate(2px, 0); + filter: brightness(0.1); + } + + 89% { + transform: translate(-2px, 1px); + filter: brightness(0.5); + } + + 90% { + transform: translate(0, -1px); + filter: brightness(0.8); + } + } +} diff --git a/src/styles/global.css b/src/styles/global.css index ce88068..9faeea6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,8 +1,5 @@ -@layer reset, tokens, base, components, utilities; - -@import url("base/foundation.css"); -@import url("base/colors.css"); -@import url("base/primitives.css"); +@import url("base/index.css"); +@import url("components/index.css"); @layer base { body { diff --git a/src/styles/mixins/borders.css b/src/styles/mixins/borders.css new file mode 100644 index 0000000..52a4d20 --- /dev/null +++ b/src/styles/mixins/borders.css @@ -0,0 +1,60 @@ +@define-mixin border-l $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-left: $width $style $color; +} + +@define-mixin border-r $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-right: $width $style $color; +} + +@define-mixin border-t $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-top: $width $style $color; +} + +@define-mixin border-b $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-bottom: $width $style $color; +} + +@define-mixin border-x $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-right: $width $style $color; + border-left: $width $style $color; +} + +@define-mixin border-y $width: var(--size-1), $style: solid, + $color: var(--color-surface-inverse) { + border-top: $width $style $color; + border-bottom: $width $style $color; +} + +@define-mixin rounded-top $radius { + border-top-left-radius: $radius; + border-top-right-radius: $radius; +} + +@define-mixin rounded-bottom $radius { + border-bottom-right-radius: $radius; + border-bottom-left-radius: $radius; +} + +@define-mixin rounded-left $radius { + border-top-left-radius: $radius; + border-bottom-left-radius: $radius; +} + +@define-mixin rounded-right $radius { + border-top-left-radius: $radius; + border-bottom-left-radius: $radius; +} + +@define-mixin circle { + border-radius: 50%; +} + +@define-mixin focus-ring $width: 2px, $color: var(--color-focus-ring), $offset: 2px { + outline: $width solid $color; + outline-offset: $offset; +} diff --git a/src/styles/mixins/helpers.css b/src/styles/mixins/helpers.css new file mode 100644 index 0000000..e85a66c --- /dev/null +++ b/src/styles/mixins/helpers.css @@ -0,0 +1,41 @@ +@define-mixin layout-wrapper { + @mixin mx auto; + @mixin px var(--layout-page-margin); + + width: 100%; + max-width: var(--layout-wrapper-width); + + @media screen and (--bp-desktop) { + @mixin px var(--space-0); + } +} + +@define-mixin layout-panel { + @mixin mx auto; + + width: min(100% - 2 * var(--layout-page-margin), var(--layout-wrapper-width)); +} + +@define-mixin layout-columns { + grid-template-columns: + [rail-start] minmax(var(--layout-rail-width), 1fr) + [rail-end body-start] var(--layout-content-width) + [body-end margin-start] var(--layout-margin-width) [margin-end]; + column-gap: var(--layout-gutter); +} + +@define-mixin visually-hidden { + position: absolute; + + overflow: hidden; + + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + + white-space: nowrap; + + clip-path: inset(50%); +} diff --git a/src/styles/mixins/spacing.css b/src/styles/mixins/spacing.css new file mode 100644 index 0000000..c199ac7 --- /dev/null +++ b/src/styles/mixins/spacing.css @@ -0,0 +1,85 @@ +@define-mixin ma $spacing { + margin: $spacing; +} + +@define-mixin mt $spacing { + margin-top: $spacing; +} + +@define-mixin mb $spacing { + margin-bottom: $spacing; +} + +@define-mixin ml $spacing { + margin-left: $spacing; +} + +@define-mixin mr $spacing { + margin-right: $spacing; +} + +@define-mixin my $spacing { + margin-block: $spacing; +} + +@define-mixin mx $spacing { + margin-inline: $spacing; +} + +@define-mixin ms $spacing { + margin-inline-start: $spacing; +} + +@define-mixin me $spacing { + margin-inline-end: $spacing; +} + +@define-mixin space-x $spacing $reverse: 0 { + & > :not(:last-child) { + margin-inline-start: calc($spacing * $reverse); + margin-inline-end: calc($spacing * (1 - $reverse)); + } +} + +@define-mixin pa $spacing { + padding: $spacing; +} + +@define-mixin pt $spacing { + padding-top: $spacing; +} + +@define-mixin pb $spacing { + padding-bottom: $spacing; +} + +@define-mixin pl $spacing { + padding-left: $spacing; +} + +@define-mixin pr $spacing { + padding-right: $spacing; +} + +@define-mixin py $spacing { + padding-block: $spacing; +} + +@define-mixin px $spacing { + padding-inline: $spacing; +} + +@define-mixin ps $spacing { + padding-inline-start: $spacing; +} + +@define-mixin pe $spacing { + padding-inline-end: $spacing; +} + +@define-mixin space-y $spacing, $reverse: 0 { + & > :not(:last-child) { + margin-block-start: calc($spacing * $reverse); + margin-block-end: calc($spacing * (1 - $reverse)); + } +} diff --git a/src/styles/mixins/typography.css b/src/styles/mixins/typography.css new file mode 100644 index 0000000..c54e4bc --- /dev/null +++ b/src/styles/mixins/typography.css @@ -0,0 +1,128 @@ +@define-mixin typo_body { + font-family: var(--font-body); + font-size: var(--type-0); + font-weight: var(--font-weight-body); + line-height: var(--leading-prose); + letter-spacing: var(--tracking-0); +} + +@define-mixin typo_heading-2 { + font-family: var(--font-header); + font-size: var(--type-4); + font-weight: var(--font-weight-heading); + line-height: var(--leading-4); + text-transform: uppercase; + letter-spacing: var(--tracking-4); +} + +@define-mixin typo_heading-3 { + font-family: var(--font-header); + font-size: var(--type-1); + font-weight: var(--font-weight-heading); + line-height: var(--leading-1); + text-transform: uppercase; + letter-spacing: var(--tracking-1); +} + +@define-mixin typo_heading-4 { + font-family: var(--font-header); + font-size: var(--type-0); + font-weight: var(--font-weight-label); + line-height: var(--leading--1); + text-transform: uppercase; + letter-spacing: var(--tracking-label); +} + +@define-mixin typo_code { + font-family: var(--font-mono); + font-size: var(--type--1); + font-weight: var(--font-weight-body); + line-height: var(--leading--1); + letter-spacing: var(--tracking-0); +} + +@define-mixin typo_table-header { + font-family: var(--font-header); + font-size: var(--type--2); + font-weight: var(--font-weight-emphasis); + line-height: var(--leading-0); + text-transform: uppercase; + letter-spacing: var(--tracking-label); +} + +@define-mixin typo_table-data { + font-family: var(--font-mono); + font-size: var(--type--1); + font-weight: var(--font-weight-body); + line-height: var(--leading--1); + letter-spacing: var(--tracking-0); +} + +@define-mixin typo_definition-header { + font-family: var(--font-header); + font-size: var(--type-0); + font-weight: var(--font-weight-emphasis); + line-height: var(--leading-0); + text-transform: uppercase; + letter-spacing: var(--tracking-0); +} + +@define-mixin typo_definition-data { + font-family: var(--font-body); + font-size: var(--type--1); + font-weight: var(--font-weight-body); + line-height: var(--leading--1); + letter-spacing: var(--tracking-0); +} + +@define-mixin typo_fine { + font-family: var(--font-body); + font-size: var(--type--3); + font-weight: var(--font-weight-body); + line-height: var(--leading--3); + letter-spacing: var(--tracking--3); +} + +@define-mixin typo_label { + font-family: var(--font-header); + font-size: var(--type--1); + font-weight: var(--font-weight-label); + line-height: var(--leading--1); + text-transform: uppercase; + letter-spacing: var(--tracking-label); +} + +@define-mixin typo_body-small { + font-family: var(--font-body); + font-size: var(--type--1); + font-weight: var(--font-weight-body); + line-height: var(--leading-prose-tight); + letter-spacing: var(--tracking--1); +} + +@define-mixin typo_eyebrow { + font-family: var(--font-mono); + font-size: var(--type--3); + font-weight: var(--font-weight-body); + line-height: var(--leading--3); + text-transform: uppercase; + letter-spacing: var(--tracking-eyebrow); +} + +@define-mixin typo_tag { + font-family: var(--font-mono); + font-size: var(--type--2); + font-weight: var(--font-weight-emphasis); + line-height: var(--leading--2); + text-transform: uppercase; + letter-spacing: var(--tracking-tag); +} + +@define-mixin typo_standfirst { + font-family: var(--font-header); + font-size: var(--type-1); + font-weight: var(--font-weight-body); + line-height: var(--leading-1); + text-transform: uppercase; + letter-spacing: var(--tracking-subtitle); +} diff --git a/tsconfig.json b/tsconfig.json index c1167f5..895a067 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "jsxImportSource": "react", "paths": { "@/*": ["./src/*"], + "~/*": ["./*"], }, }, }