diff --git a/package.json b/package.json index 5dd5dae..7b1939a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build:content": "tsx scripts/build-content.ts", "dev": "npm run build:content && astro dev", "dev:watch": "concurrently \"tsx watch scripts/build-content.ts\" \"astro dev\"", - "build": "npm run build:content && astro build && tsx scripts/generate-sitemap.ts", + "build": "npm run build:content && astro build && tsx scripts/generate-sitemap.ts && tsx scripts/verify-dist.ts", "preview": "astro preview", "test": "vitest run", "astro": "astro" diff --git a/scripts/islands.test.ts b/scripts/islands.test.ts new file mode 100644 index 0000000..03b5c20 --- /dev/null +++ b/scripts/islands.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const SOURCE_DIRS = ['src/components', 'src/composables', 'src/layouts', 'src/pages'] + +function walk(dir: string): string[] { + const files: string[] = [] + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) files.push(...walk(full)) + else files.push(full) + } + return files +} + +const vueFiles = SOURCE_DIRS.flatMap((dir) => walk(dir)).filter((f) => f.endsWith('.vue')) + +// Vue islands must hydrate cleanly: the SSR output embedded in the page and the +// client render have to match. These guards keep known hydration breakers out +// of island components. The regression they encode: the Astro migration shipped +// SiteHeader with , whose SSR teleport anchors cannot be +// matched on hydration, breaking every page. +describe('Vue island architecture', () => { + it('has Vue components to check', () => { + expect(vueFiles.length).toBeGreaterThan(0) + }) + + it('uses no — SSR renders teleport anchors the browser cannot match on hydration', () => { + const offenders = vueFiles.filter((f) => / { + const offenders = vueFiles.filter((f) => /vue-router/.test(readFileSync(f, 'utf-8'))) + expect(offenders, `vue-router import found in: ${offenders.join(', ')}`).toEqual([]) + }) + + it('touches no browser globals outside lifecycle hooks — render must be identical on server and client', () => { + const browserGlobals = /\b(window|document|localStorage|sessionStorage|navigator)\b/ + const offenders: string[] = [] + for (const file of vueFiles) { + const source = readFileSync(file, 'utf-8') + const setup = source.match(/ diff --git a/src/components/SiteHeader.vue b/src/components/SiteHeader.vue deleted file mode 100644 index fd345cc..0000000 --- a/src/components/SiteHeader.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - diff --git a/src/components/ui/DarkModeToggle.vue b/src/components/ui/DarkModeToggle.vue deleted file mode 100644 index 7753c2f..0000000 --- a/src/components/ui/DarkModeToggle.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts deleted file mode 100644 index f80a09c..0000000 --- a/src/composables/useTheme.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ref, watch, onMounted } from 'vue' - -export function useTheme() { - const isDark = ref(false) - - onMounted(() => { - const stored = localStorage.getItem('theme') - if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - isDark.value = true - } - applyTheme() - }) - - function applyTheme() { - document.documentElement.classList.toggle('dark', isDark.value) - } - - function toggleTheme() { - isDark.value = !isDark.value - localStorage.setItem('theme', isDark.value ? 'dark' : 'light') - applyTheme() - } - - return { isDark, toggleTheme } -} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 75d8906..65dc000 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,7 +1,7 @@ --- import '@/styles/main.css' import '@/styles/asciidoc.css' -import SiteHeader from '@/components/SiteHeader.vue' +import SiteHeader from '@/components/SiteHeader.astro' import SiteFooter from '@/components/SiteFooter.astro' interface Props { @@ -16,7 +16,10 @@ const { const siteTitle = 'EXPRESS Language Foundation' const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle -const canonical = `https://www.expresslang.org${Astro.url.pathname}` +// build.format "file" exposes Astro.url.pathname as "/about.html" (and "/index" for home) — +// public URLs never carry those suffixes +const currentPath = Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/') +const canonical = `https://www.expresslang.org${currentPath}` --- @@ -67,7 +70,7 @@ const canonical = `https://www.expresslang.org${Astro.url.pathname}` Skip to content
- +
diff --git a/src/styles/main.css b/src/styles/main.css index 22e4ba0..d0e0f71 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -89,3 +89,15 @@ hr { opacity: 1; transform: none; } + +/* Site header scrolled state (toggled by script in SiteHeader.astro) */ +[data-site-header].is-scrolled { + background-color: color-mix(in srgb, white 90%, transparent); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + box-shadow: 0 1px 2px 0 rgb(17 24 39 / 0.05); +} +.dark [data-site-header].is-scrolled { + background-color: color-mix(in srgb, var(--color-navy) 90%, transparent); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2); +}