diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..f8344bb --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +a484cdd7cec5851f1d65c068a7de69e21d30a388 # prettier, 14 files diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a646416..554fa37 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,7 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 5 @@ -20,7 +20,7 @@ updates: update-types: [minor, patch] - package-ecosystem: github-actions - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b698663 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,31 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Generated during a build, so it is absent locally and present in CI — which +# makes a clean local --check no evidence at all. Contentlayer's output also +# uses import assertions, which prettier's parser rejects outright. +.contentlayer +.astro +.svelte-kit +storybook-static +test-results +playwright-report + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..616247a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 6d44f95..8e20492 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,9 @@ // Flat config (ESLint 9). Recommended presets only — same reasoning as // limitkit: a small library does not need a bespoke rule set to maintain. // The floor is "lint runs and can fail", not "lint encodes taste". -import js from '@eslint/js' -import globals from 'globals' -import tseslint from 'typescript-eslint' +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default tseslint.config( { @@ -21,4 +21,4 @@ export default tseslint.config( files: ['test/**/*.js'], languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } }, }, -) +); diff --git a/package-lock.json b/package-lock.json index 9537f1c..2bd259b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sitekit", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sitekit", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "zod": "^4.0.0" @@ -17,13 +17,14 @@ "@types/react": "^19.0.0", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.8.2", "typescript-eslint": "^8.67.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "react": ">=18" @@ -1335,6 +1336,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index e81b03e..a33ae3b 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,10 @@ "lint": "eslint .", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.js", - "verify": "npm run lint && npm run typecheck && npm run build && npm test", - "prepare": "npm run build" + "verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test", + "prepare": "npm run build", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "zod": "^4.0.0" @@ -58,6 +60,7 @@ "@types/react": "^19.0.0", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.8.2", diff --git a/src/model.ts b/src/model.ts index 9d5642b..c31babc 100644 --- a/src/model.ts +++ b/src/model.ts @@ -17,7 +17,9 @@ export function validateSite( if (result.success) return { success: true, data: result.data }; return { success: false, - errors: result.error.issues.map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`), + errors: result.error.issues.map( + (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`, + ), }; } @@ -25,7 +27,7 @@ export function validateSite( export function siteNavItems(pages: SitePage[]): SiteNavItem[] { return pages .filter((page): page is SitePage & { navLabel: string } => Boolean(page.navLabel)) - .map(page => ({ path: page.path, label: page.navLabel })); + .map((page) => ({ path: page.path, label: page.navLabel })); } /** @@ -39,7 +41,7 @@ export function pageRendersOwnHeader(page: SitePage): boolean { /** @returns the page at this path, or null. */ export function sitePageAt(pages: SitePage[], path: string): SitePage | null { const normalised = path.replace(/^\/+|\/+$/g, ''); - return pages.find(page => page.path === normalised) ?? null; + return pages.find((page) => page.path === normalised) ?? null; } /** An in-site link. Root is '/', everything else '/segment'. */ diff --git a/src/react/SiteNav.tsx b/src/react/SiteNav.tsx index 9636d9e..3920239 100644 --- a/src/react/SiteNav.tsx +++ b/src/react/SiteNav.tsx @@ -30,7 +30,7 @@ export function SiteNav({ items, currentPath, Link = DefaultLink }: Props) { aria-label="Sections" className="scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto" > - {items.map(item => { + {items.map((item) => { const isCurrent = item.path === currentPath; return ( ; -export const DefaultLink: LinkLike = props => createElement('a', props); +export const DefaultLink: LinkLike = (props) => createElement('a', props); diff --git a/src/react/sections/BusinessSections.tsx b/src/react/sections/BusinessSections.tsx index acf3c4c..2548316 100644 --- a/src/react/sections/BusinessSections.tsx +++ b/src/react/sections/BusinessSections.tsx @@ -25,11 +25,7 @@ export function FeatureSection({
{section.heading && {section.heading}} -
+
{section.image && ( // Plain : the package cannot depend on a framework's image // component, and a generated site's images are already sized by @@ -87,7 +83,7 @@ export function ContactSection({ {section.blurb &&
{{section.blurb}}
}
- {section.locations.map(location => ( + {section.locations.map((location) => (
0 && (
    - {location.hours.map(line => ( + {location.hours.map((line) => (
  • {line}
  • ))}
@@ -144,7 +140,7 @@ export function FaqSection({ {section.heading && {section.heading}}
- {section.items.map(item => ( + {section.items.map((item) => (
{section.blurb}}
- +
{section.columns.map((_, i) => ( @@ -84,7 +87,7 @@ export function TableSection({ section }: { section: Extract - {section.columns.map(column => ( + {section.columns.map((column) => (
{section.heading && {section.heading}}
- {section.stats.map(stat => ( + {section.stats.map((stat) => (
{stat.label} diff --git a/src/react/sections/ProseSections.tsx b/src/react/sections/ProseSections.tsx index 9676052..5d2cd6a 100644 --- a/src/react/sections/ProseSections.tsx +++ b/src/react/sections/ProseSections.tsx @@ -50,7 +50,7 @@ export function CardsSection({ {section.blurb &&
{{section.blurb}}
}
- {section.cards.map(card => ( + {section.cards.map((card) => (
{card.icon && ( @@ -83,7 +83,7 @@ export function DefinitionsSection({ {section.blurb &&
{{section.blurb}}
}
- {section.items.map(item => ( + {section.items.map((item) => (
{item.term}
diff --git a/src/schema.ts b/src/schema.ts index 8088667..7434247 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -294,7 +294,7 @@ export const siteSpecSchema = z // actually click — checked per page, where the fragments live. Hero // actions get the same check when they point at a fragment. const anchors = new Set( - page.sections.flatMap(s => ('anchor' in s && s.anchor ? [s.anchor] : [])), + page.sections.flatMap((s) => ('anchor' in s && s.anchor ? [s.anchor] : [])), ); page.sections.forEach((section, j) => { if (section.kind !== 'hero' || !section.actions) return; diff --git a/test/model.test.js b/test/model.test.js index 3dc4307..e235e12 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -4,8 +4,18 @@ import assert from 'node:assert/strict'; import { siteNavItems, pageRendersOwnHeader, sitePageAt, href } from 'sitekit'; const PAGES = [ - { path: '', navLabel: 'Home', title: 'Home', sections: [{ kind: 'hero', statement: 'Hi', lead: [] }] }, - { path: 'menu', navLabel: 'Menu', title: 'Menu', sections: [{ kind: 'prose', paragraphs: ['x'] }] }, + { + path: '', + navLabel: 'Home', + title: 'Home', + sections: [{ kind: 'hero', statement: 'Hi', lead: [] }], + }, + { + path: 'menu', + navLabel: 'Menu', + title: 'Menu', + sections: [{ kind: 'prose', paragraphs: ['x'] }], + }, { path: 'imprint', title: 'Imprint', sections: [{ kind: 'prose', paragraphs: ['x'] }] }, ]; diff --git a/test/provenance.test.js b/test/provenance.test.js index c2ec86c..78ad4ac 100644 --- a/test/provenance.test.js +++ b/test/provenance.test.js @@ -6,12 +6,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - provenanceSchema, - inferredPaths, - notFoundPaths, - assertDeliverable, -} from 'sitekit'; +import { provenanceSchema, inferredPaths, notFoundPaths, assertDeliverable } from 'sitekit'; const PROV = { 'chrome.name': { kind: 'scraped', url: 'https://cafe-beispiel.ch/' }, diff --git a/test/react.test.js b/test/react.test.js index fc2cd2d..1b8fb4d 100644 --- a/test/react.test.js +++ b/test/react.test.js @@ -13,27 +13,88 @@ import { SiteSections, SiteMasthead, SiteFooter } from 'sitekit/react'; const SECTIONS = [ { kind: 'hero', eyebrow: 'Zürich', statement: 'Coffee, done properly.', lead: ['Since 2011.'] }, { kind: 'prose', heading: 'About', paragraphs: ['A neighbourhood café.'] }, - { kind: 'stats', heading: 'In numbers', stats: [{ label: 'Seats', value: '24', note: 'inside' }] }, + { + kind: 'stats', + heading: 'In numbers', + stats: [{ label: 'Seats', value: '24', note: 'inside' }], + }, { kind: 'meter', label: 'Sourced', value: 3, of: 12, caption: 'so far' }, - { kind: 'cards', heading: 'Offers', columns: 3, cards: [{ title: 'Espresso', body: 'The house shot.', meta: 'CHF 4.50' }] }, - { kind: 'definitions', heading: 'Terms', items: [{ term: 'Cortado', detail: 'Espresso cut with milk.' }] }, - { kind: 'index', heading: 'Contents', entries: [{ label: 'Opening hours', anchor: 'hours', meta: '7d' }] }, { - kind: 'table', anchor: 'hours', heading: 'Hours', columns: ['Day', 'Hours', 'Status'], - rows: [['Mon–Fri', '07–18', 'done']], monoColumns: [1], statusColumn: 2, note: 'Holidays differ.', + kind: 'cards', + heading: 'Offers', + columns: 3, + cards: [{ title: 'Espresso', body: 'The house shot.', meta: 'CHF 4.50' }], + }, + { + kind: 'definitions', + heading: 'Terms', + items: [{ term: 'Cortado', detail: 'Espresso cut with milk.' }], + }, + { + kind: 'index', + heading: 'Contents', + entries: [{ label: 'Opening hours', anchor: 'hours', meta: '7d' }], + }, + { + kind: 'table', + anchor: 'hours', + heading: 'Hours', + columns: ['Day', 'Hours', 'Status'], + rows: [['Mon–Fri', '07–18', 'done']], + monoColumns: [1], + statusColumn: 2, + note: 'Holidays differ.', + }, + { + kind: 'feature', + heading: 'The baker', + paragraphs: ['Learned in Lyon.'], + quote: 'Every croissant tells.', + cta: { label: 'More', href: '/about' }, + image: { src: '/founder.jpg', alt: 'The founder' }, + }, + { + kind: 'contact', + heading: 'Find us', + anchor: 'standorte', + locations: [ + { + name: 'Café', + note: 'Open Sundays', + address: 'Limmatquai 42, 8001 Zürich', + phone: '+41 44 000 12 34', + hours: ['Mo–Fr: 06:30–18:00'], + }, + ], + }, + { + kind: 'faq', + heading: 'Good to know', + items: [{ question: 'Can I order ahead?', answer: 'Yes, until 05:00.' }], }, - { kind: 'feature', heading: 'The baker', paragraphs: ['Learned in Lyon.'], quote: 'Every croissant tells.', cta: { label: 'More', href: '/about' }, image: { src: '/founder.jpg', alt: 'The founder' } }, - { kind: 'contact', heading: 'Find us', anchor: 'standorte', locations: [{ name: 'Café', note: 'Open Sundays', address: 'Limmatquai 42, 8001 Zürich', phone: '+41 44 000 12 34', hours: ['Mo–Fr: 06:30–18:00'] }] }, - { kind: 'faq', heading: 'Good to know', items: [{ question: 'Can I order ahead?', answer: 'Yes, until 05:00.' }] }, ]; test('every section kind renders its content', () => { const html = renderToStaticMarkup(h(SiteSections, { sections: SECTIONS })); for (const needle of [ - 'Coffee, done properly.', 'A neighbourhood café.', 'Seats', '25%', 'Espresso', - 'CHF 4.50', 'Cortado', '#hours', 'id="hours"', 'Mon–Fri', 'Holidays differ.', - 'Learned in Lyon.', '«Every croissant tells.»', 'src="/founder.jpg"', - 'id="standorte"', 'tel:+41440001234', 'Limmatquai 42', 'Can I order ahead?', + 'Coffee, done properly.', + 'A neighbourhood café.', + 'Seats', + '25%', + 'Espresso', + 'CHF 4.50', + 'Cortado', + '#hours', + 'id="hours"', + 'Mon–Fri', + 'Holidays differ.', + 'Learned in Lyon.', + '«Every croissant tells.»', + 'src="/founder.jpg"', + 'id="standorte"', + 'tel:+41440001234', + 'Limmatquai 42', + 'Can I order ahead?', ]) { assert.ok(html.includes(needle), `expected rendered html to contain ${JSON.stringify(needle)}`); } @@ -48,17 +109,27 @@ test('numbering counts only headed sections, hero and table take none', () => { assert.ok(!html.includes('>09<'), 'only headed, numberable sections take a number'); }); -const CHROME = { name: 'Café Beispiel', tagline: 'Kaffee', footerNote: 'Run by its owners.', host: 'beispiel.ch' }; -const NAV = [{ path: '', label: 'Home' }, { path: 'menu', label: 'Menu' }]; +const CHROME = { + name: 'Café Beispiel', + tagline: 'Kaffee', + footerNote: 'Run by its owners.', + host: 'beispiel.ch', +}; +const NAV = [ + { path: '', label: 'Home' }, + { path: 'menu', label: 'Menu' }, +]; test('masthead defaults to plain anchors', () => { - const html = renderToStaticMarkup(h(SiteMasthead, { chrome: CHROME, navItems: NAV, currentPath: 'menu' })); + const html = renderToStaticMarkup( + h(SiteMasthead, { chrome: CHROME, navItems: NAV, currentPath: 'menu' }), + ); assert.ok(html.includes(' { - const FakeLink = props => h('a', { ...props, 'data-framework': 'yes' }); + const FakeLink = (props) => h('a', { ...props, 'data-framework': 'yes' }); const html = renderToStaticMarkup( h(SiteMasthead, { chrome: CHROME, navItems: NAV, currentPath: '', Link: FakeLink }), ); @@ -75,26 +146,42 @@ test('footer shows host only when given', () => { }); test('hero actions render: first filled, second outlined', () => { - const html = renderToStaticMarkup(h(SiteSections, { sections: [ - { kind: 'hero', statement: 'Hi', lead: [], actions: [ - { label: 'Primary', href: '#a' }, { label: 'Secondary', href: '#b' }, - ] }, - ] })); + const html = renderToStaticMarkup( + h(SiteSections, { + sections: [ + { + kind: 'hero', + statement: 'Hi', + lead: [], + actions: [ + { label: 'Primary', href: '#a' }, + { label: 'Secondary', href: '#b' }, + ], + }, + ], + }), + ); assert.ok(html.includes('bg-accent') && html.includes('border-2')); assert.ok(html.includes('Primary') && html.includes('Secondary')); }); test('a card icon renders aria-hidden', () => { - const html = renderToStaticMarkup(h(SiteSections, { sections: [ - { kind: 'cards', cards: [{ title: 'Croissant', body: 'Butter.', icon: '\u{1F950}' }] }, - ] })); + const html = renderToStaticMarkup( + h(SiteSections, { + sections: [ + { kind: 'cards', cards: [{ title: 'Croissant', body: 'Butter.', icon: '\u{1F950}' }] }, + ], + }), + ); assert.ok(html.includes('aria-hidden')); }); test('contact omits what the data omits — no hours, no hours list', () => { - const html = renderToStaticMarkup(h(SiteSections, { sections: [ - { kind: 'contact', locations: [{ name: 'Atelier', address: 'Hardstrasse 15' }] }, - ] })); + const html = renderToStaticMarkup( + h(SiteSections, { + sections: [{ kind: 'contact', locations: [{ name: 'Atelier', address: 'Hardstrasse 15' }] }], + }), + ); assert.ok(html.includes('Hardstrasse 15')); assert.ok(!html.includes(' { spec.pages[0].sections.push({ kind: 'carousel', images: [] }); const result = validateSite(spec); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.includes('sections'))); + assert.ok(result.errors.some((e) => e.includes('sections'))); }); test('a hero anywhere but first is rejected', () => { @@ -54,7 +104,10 @@ test('a hero anywhere but first is rejected', () => { spec.pages[0].sections.push({ kind: 'hero', statement: 'Second hero', lead: [] }); const result = validateSite(spec); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.includes('first section')), result.errors.join('\n')); + assert.ok( + result.errors.some((e) => e.includes('first section')), + result.errors.join('\n'), + ); }); test('an index entry pointing at a missing anchor is rejected', () => { @@ -62,7 +115,10 @@ test('an index entry pointing at a missing anchor is rejected', () => { spec.pages[0].sections[9].entries.push({ label: 'Nowhere', anchor: 'missing' }); const result = validateSite(spec); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.includes("'#missing'")), result.errors.join('\n')); + assert.ok( + result.errors.some((e) => e.includes("'#missing'")), + result.errors.join('\n'), + ); }); test('duplicate page paths are rejected', () => { @@ -70,7 +126,7 @@ test('duplicate page paths are rejected', () => { spec.pages.push({ ...fullSpec().pages[0] }); const result = validateSite(spec); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.includes('duplicate page path'))); + assert.ok(result.errors.some((e) => e.includes('duplicate page path'))); }); test('a multi-segment path is rejected — pages are one segment deep', () => { @@ -83,7 +139,10 @@ test('a multi-segment path is rejected — pages are one segment deep', () => { test('errors carry actionable paths, not just messages', () => { const result = validateSite({ chrome: { name: '' }, pages: [] }); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.startsWith('chrome.')), result.errors.join('\n')); + assert.ok( + result.errors.some((e) => e.startsWith('chrome.')), + result.errors.join('\n'), + ); }); test('a bare section validates against the section schema directly', () => { @@ -96,7 +155,10 @@ test('a hero action pointing at a missing fragment is rejected', () => { spec.pages[0].sections[0].actions = [{ label: 'Nowhere', href: '#missing' }]; const result = validateSite(spec); assert.equal(result.success, false); - assert.ok(result.errors.some(e => e.includes("'#missing'")), result.errors.join('\n')); + assert.ok( + result.errors.some((e) => e.includes("'#missing'")), + result.errors.join('\n'), + ); }); test('a hero action pointing at a real anchor validates', () => {