Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
58 changes: 58 additions & 0 deletions scripts/islands.test.ts
Original file line number Diff line number Diff line change
@@ -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 <Teleport to="body">, 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 <Teleport> — SSR renders teleport anchors the browser cannot match on hydration', () => {
const offenders = vueFiles.filter((f) => /<Teleport/.test(readFileSync(f, 'utf-8')))
expect(offenders, `Teleport found in: ${offenders.join(', ')}`).toEqual([])
})

it('imports no vue-router — router APIs do not exist inside Astro islands', () => {
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(/<script setup[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? ''
// onMounted/onUnmounted bodies may use browser APIs — they run only in the browser, after hydration
const stripped = setup.replace(/on(Mounted|Unmounted)\(\(\)\s*=>\s*\{[\s\S]*?\n\s*\}\)/g, '')
if (browserGlobals.test(stripped)) offenders.push(file)
}
expect(offenders, `browser global in island render path: ${offenders.join(', ')}`).toEqual([])
})
})

describe('layout pathname hygiene', () => {
it('strips the .html suffix from Astro.url.pathname before using it', () => {
const layout = readFileSync('src/layouts/BaseLayout.astro', 'utf-8')
expect(layout).toMatch(/pathname\.replace\(\s*\/\\\.html\$\//)
})
})
67 changes: 67 additions & 0 deletions scripts/verify-dist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'

// Post-build invariants over dist/ — run by `npm run build` after `astro build`.
// Encodes the regressions of the Astro migration: build.format "file" leaked
// ".html" into canonical URLs and serialized island props, and hydration
// depends on every island's assets actually being emitted.

const DIST = new URL('../dist', import.meta.url).pathname
const errors: string[] = []

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 pages = walk(DIST).filter((f) => f.endsWith('.html'))
const canonicalExempt = new Set(['404.html', 'references.html'])
let canonicals = 0
let islands = 0

for (const page of pages) {
const rel = relative(DIST, page)
const html = readFileSync(page, 'utf-8')

const canonical = html.match(/<link rel="canonical" href="([^"]+)"/)?.[1]
if (!canonicalExempt.has(rel)) {
canonicals++
if (canonical.endsWith('.html')) errors.push(`${rel}: canonical ends with .html: ${canonical}`)
const expectedPath = '/' + rel.replace(/(?:^|\/)index\.html$/, '').replace(/\.html$/, '')
const expected = `https://www.expresslang.org${expectedPath}`
if (canonical !== expected) errors.push(`${rel}: canonical ${canonical} != expected ${expected}`)
} else if (!canonicalExempt.has(rel)) {
errors.push(`${rel}: missing canonical link`)
}

for (const match of html.matchAll(/props="([^"]*)"/g)) {
const props = match[1]
if (/\.html/.test(props)) errors.push(`${rel}: island props contain .html path: ${props.slice(0, 120)}`)
}

for (const island of html.matchAll(/<astro-island[^>]*component-url="([^"]+)"[^>]*>/g)) {
islands++
const component = island[1]
const assetPath = join(DIST, component.replace(/^\//, ''))
if (!existsSync(assetPath)) errors.push(`${rel}: island component-url not emitted: ${component}`)
const ssr = html.slice(html.indexOf(island[0]))
const inner = ssr.slice(island[0].length, ssr.indexOf('</astro-island>'))
if (inner.trim().length === 0) errors.push(`${rel}: island rendered empty SSR content: ${component}`)
}
}

if (islands === 0) errors.push('no islands found in build output — island detection is broken or islands were removed')
if (canonicals === 0) errors.push('no canonical links found in build output')

if (errors.length) {
console.error(`\nverify-dist failed (${errors.length} problem${errors.length === 1 ? '' : 's'}):`)
for (const error of errors) console.error(` ✗ ${error}`)
process.exit(1)
}

console.log(`verify-dist: ${pages.length} pages, ${canonicals} canonicals, ${islands} islands — all invariants hold`)
195 changes: 195 additions & 0 deletions src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
---
import { mainNavigation, type NavItem } from '@/data/navigation'

interface Props {
currentPath: string
}

const { currentPath } = Astro.props

function isParent(item: NavItem): boolean {
return !!(item.children && item.children.length)
}

function isActive(item: NavItem): boolean {
if (item.path && (currentPath === item.path || currentPath.startsWith(item.path + '/'))) return true
if (item.children)
return item.children.some((c) => c.path && (currentPath === c.path || currentPath.startsWith(c.path + '/')))
return false
}

const linkClasses =
'relative px-3 py-2 text-[0.8rem] font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md transition-colors duration-150'
const activeText = '!text-elf-blue dark:!text-elf-blue'
---

<header data-site-header class="sticky top-0 z-40 bg-white dark:bg-navy transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2.5 shrink-0 group" data-close-mobile-nav>
<img src="/logos/logo-icon-white.svg" alt="" class="h-8 shrink-0 dark:hidden" />
<img src="/logos/logo-icon-blue.svg" alt="" class="h-8 shrink-0 hidden dark:block" />
<div class="flex flex-col leading-none">
<span class="font-[Montserrat,sans-serif] font-bold text-[0.9rem] tracking-[0.06em] text-elf-blue dark:text-white">EXPRESS</span>
<span class="font-[Montserrat,sans-serif] font-medium text-[0.58rem] tracking-[0.1em] text-elf-blue/70 dark:text-gray-400 mt-[3px]">Language Foundation</span>
</div>
</a>

<!-- Desktop nav -->
<nav class="hidden lg:flex items-center gap-0.5" aria-label="Main navigation">
{
mainNavigation.map((item) =>
!isParent(item) ? (
<a href={item.path} class:list={[linkClasses, isActive(item) && activeText]}>
{item.title}
{isActive(item) && <span class="absolute bottom-0 left-3 right-3 h-0.5 bg-elf-blue dark:bg-elf-blue rounded-full" />}
</a>
) : (
<div class="relative group">
<a href={item.path} class:list={[linkClasses, 'inline-flex items-center gap-1', isActive(item) && activeText]}>
{item.title}
<svg class="w-3 h-3 mt-px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
{isActive(item) && <span class="absolute bottom-0 left-3 right-3 h-0.5 bg-elf-blue dark:bg-elf-blue rounded-full" />}
</a>
<div class="absolute right-0 mt-1 w-44 rounded-lg bg-white dark:bg-navy-light border border-gray-200/80 dark:border-gray-700/60 shadow-lg shadow-gray-900/5 dark:shadow-black/20 py-1.5 z-50 opacity-0 scale-95 pointer-events-none origin-top-right transition duration-150 ease-out group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:scale-100 group-focus-within:pointer-events-auto">
{item.children!.map((child) => (
<a
href={child.path}
class:list={[
'block px-4 py-2 text-[0.8rem] text-gray-600 dark:text-gray-300 hover:text-elf-blue dark:hover:text-elf-blue hover:bg-gray-50 dark:hover:bg-navy transition-colors',
currentPath === child.path && 'text-elf-blue dark:text-elf-blue',
]}
>
{child.title}
</a>
))}
</div>
</div>
),
)
}
</nav>

<!-- Right side -->
<div class="flex items-center gap-2">
<button
data-theme-toggle
class="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-elf-blue dark:hover:text-elf-blue transition-colors duration-150"
aria-label="Toggle dark mode"
>
<svg class="w-5 h-5 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
<a
href="https://github.com/expresslang"
target="_blank"
rel="noopener"
class="hidden sm:flex p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-md"
aria-label="GitHub"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>
</a>
<button
data-mobile-nav-toggle
class="lg:hidden p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
aria-expanded="false"
aria-controls="mobile-nav"
aria-label="Toggle menu"
>
<svg class="w-6 h-6" data-icon-open fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
<svg class="w-6 h-6 hidden" data-icon-close fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>

<!-- Mobile nav drawer (sibling of header: no backdrop-filter ancestor to trap fixed positioning) -->
<div data-mobile-nav class="fixed inset-0 z-50 lg:hidden hidden" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm" data-close-mobile-nav></div>
<div id="mobile-nav" class="fixed top-0 left-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-navy shadow-2xl">
<div class="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-800">
<span class="font-serif font-bold text-gray-900 dark:text-white">Navigation</span>
<button data-close-mobile-nav class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-md min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label="Close menu">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<nav class="p-4 space-y-1" aria-label="Mobile navigation">
{
mainNavigation.map((item) =>
!isParent(item) ? (
<a
href={item.path}
data-close-mobile-nav
class:list={[
'flex items-center px-4 py-3 text-base font-medium rounded-lg transition-colors min-h-[44px]',
isActive(item)
? 'text-elf-blue dark:text-elf-blue bg-blue-50 dark:bg-navy-light'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-navy-light',
]}
>
{item.title}
</a>
) : (
<div>
<p class="px-4 pt-3 pb-1 text-[0.65rem] font-mono font-semibold uppercase tracking-[0.15em] text-gray-400 dark:text-gray-500">{item.title}</p>
{item.children!.map((child) => (
<a
href={child.path}
data-close-mobile-nav
class:list={[
'flex items-center pl-8 pr-4 py-2.5 text-[0.9rem] font-medium rounded-lg transition-colors min-h-[40px]',
currentPath === child.path
? 'text-elf-blue dark:text-elf-blue bg-blue-50 dark:bg-navy-light'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-navy-light',
]}
>
{child.title}
</a>
))}
</div>
),
)
}
</nav>
</div>
</div>
</header>

<script>
const header = document.querySelector('[data-site-header]')!
const onScroll = () => header.classList.toggle('is-scrolled', window.scrollY > 10)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })

for (const button of document.querySelectorAll<HTMLButtonElement>('[data-theme-toggle]')) {
button.addEventListener('click', () => {
const dark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', dark ? 'dark' : 'light')
})
}

const drawer = document.querySelector('[data-mobile-nav]')!
const toggle = document.querySelector<HTMLButtonElement>('[data-mobile-nav-toggle]')!
const iconOpen = toggle.querySelector('[data-icon-open]')!
const iconClose = toggle.querySelector('[data-icon-close]')!

function setNav(open: boolean) {
drawer.classList.toggle('hidden', !open)
toggle.setAttribute('aria-expanded', String(open))
iconOpen.classList.toggle('hidden', open)
iconClose.classList.toggle('hidden', !open)
document.body.style.overflow = open ? 'hidden' : ''
}

toggle.addEventListener('click', () => setNav(drawer.classList.contains('hidden')))
for (const closer of drawer.querySelectorAll('[data-close-mobile-nav]')) {
closer.addEventListener('click', () => setNav(false))
}
</script>
Loading
Loading