feat: semantic fallback page with rel=me injection

Add a Vite plugin that renders bio, links and greetings from the
Task 2 content modules into the raw served index.html at build
time, so non-JS clients (Mastodon's rel=me verifier included) see
real content and the mastodon.saillant.cc/@clement rel=me anchor.

Hide the fallback for sighted users once the 3D demo boots, but
keep it in the accessibility tree. Add @types/node so tsc can
type-check the node:fs import in the new test.
This commit is contained in:
L'électron rare
2026-07-09 11:12:28 +02:00
parent 017ffe0cf3
commit 4436877e45
9 changed files with 146 additions and 3 deletions
+25
View File
@@ -4,8 +4,33 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clément Saillant — LE BUS</title>
<meta
name="description"
content="Clément Saillant — électronique, IA souveraine, musique bruitiste. Hub perso façon demoscene : un bus sur un PCB géant."
/>
<link rel="me" href="https://mastodon.saillant.cc/@clement" />
</head>
<body>
<main id="fallback">
<h1><!--name--></h1>
<p class="tagline"><!--tagline--></p>
<section id="bio" aria-label="Bio">
<!--bio-->
</section>
<nav id="identities" aria-label="Identités">
<!--nav-->
</nav>
<section id="music" aria-label="Musique">
<!--music-->
</section>
<section id="greetings" aria-label="Greetings">
<!--greetings-->
</section>
<p class="hint">
Version animée : ce site est une demo 3D (un bus sur un PCB).
Activez JavaScript / les animations pour monter dedans.
</p>
</main>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+18
View File
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53",
"@types/node": "^26.1.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.177.0",
@@ -1415,6 +1416,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
@@ -2758,6 +2769,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53",
"@types/node": "^26.1.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.177.0",
+3
View File
@@ -0,0 +1,3 @@
export function boot(): void {
console.log('demo boot placeholder — replaced by Task 5')
}
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { renderFallbackHtml } from './inject'
import { LINKS } from '../content/links'
const template = readFileSync('index.html', 'utf8')
describe('renderFallbackHtml', () => {
it('injects every identity link into the raw HTML', () => {
const out = renderFallbackHtml(template)
for (const l of LINKS) expect(out).toContain(`href="${l.href}"`)
})
it('injects the rel=me anchor for Mastodon', () => {
const out = renderFallbackHtml(template)
expect(out).toMatch(
/<a[^>]*href="https:\/\/mastodon\.saillant\.cc\/@clement"[^>]*rel="me"/,
)
})
it('leaves no marker comments behind', () => {
const out = renderFallbackHtml(template)
expect(out).not.toMatch(/<!--(nav|bio|music|greetings|name|tagline)-->/)
})
})
+23
View File
@@ -0,0 +1,23 @@
import { BIO_LINES, NAME, TAGLINE } from '../content/bio'
import { GREETINGS } from '../content/greetings'
import { LINKS } from '../content/links'
import { MUSIC } from '../content/music'
export function renderFallbackHtml(html: string): string {
const nav = LINKS.map(
(l) =>
`<a href="${l.href}"${l.rel ? ` rel="${l.rel}"` : ''}>${l.label}</a>`,
).join('\n ')
const bio = BIO_LINES.map((line) => `<p>${line}</p>`).join('\n ')
const music =
`<h2>${MUSIC.title}</h2><p>${MUSIC.blurb}</p>` +
`<p><a href="${MUSIC.listenUrl}">${MUSIC.listenLabel}</a></p>`
const greetings = `<p>Greetings to ${GREETINGS.join(', ')}.</p>`
return html
.replace('<!--name-->', NAME)
.replace('<!--tagline-->', TAGLINE)
.replace('<!--nav-->', nav)
.replace('<!--bio-->', bio)
.replace('<!--music-->', music)
.replace('<!--greetings-->', greetings)
}
+13 -1
View File
@@ -1,3 +1,15 @@
import './style.css'
console.log('LE BUS — scaffold OK')
function canRunDemo(): boolean {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
const gl = document.createElement('canvas').getContext('webgl2')
return gl !== null
}
if (canRunDemo()) {
document.body.classList.add('demo-on')
// App is mounted in Task 5; for now the gate is in place.
import('./boot').then((m) => m.boot()).catch(() => {
document.body.classList.remove('demo-on')
})
}
+30
View File
@@ -1,2 +1,32 @@
:root { color-scheme: dark; }
body { margin: 0; background: #05010d; color: #ffd23f; font-family: "Courier New", monospace; }
#fallback {
max-width: 42rem;
margin: 0 auto;
padding: 2rem 1rem 4rem;
line-height: 1.5;
}
#fallback h1 { color: #ff5c00; font-size: 2.2rem; margin-bottom: 0; }
#fallback .tagline { margin-top: 0.2rem; color: #9be564; }
#fallback nav a {
display: block;
padding: 0.6rem 0.8rem;
margin: 0.4rem 0;
border: 3px solid #ffd23f;
color: #ffd23f;
text-decoration: none;
font-weight: bold;
}
#fallback nav a:hover, #fallback nav a:focus { background: #ffd23f; color: #05010d; }
#fallback a { color: #9be564; }
#fallback .hint { color: #666; font-size: 0.85rem; }
/* When the 3D demo is on, keep the fallback for screen readers only */
body.demo-on #fallback {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+10 -2
View File
@@ -1,6 +1,14 @@
import { defineConfig } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { renderFallbackHtml } from './src/fallback/inject'
function fallbackPlugin(): Plugin {
return {
name: 'inject-fallback',
transformIndexHtml: (html) => renderFallbackHtml(html),
}
}
export default defineConfig({
plugins: [react()],
plugins: [react(), fallbackPlugin()],
})