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:
+25
@@ -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>
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function boot(): void {
|
||||
console.log('demo boot placeholder — replaced by Task 5')
|
||||
}
|
||||
@@ -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)-->/)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user