(items: T[]): T | undefined {
+ if (items.length === 0) return undefined
+ return items[Math.floor(Math.random() * items.length)]
+}
+
+export function getRandomExplorationWithImage(): Exploration | undefined {
+ return pickRandom(Object.values(EXPLORATIONS).filter((e) => e.image))
+}
+
export function getRandomTopicExplorationImage(topicId: string): string | undefined {
const images = Object.values(EXPLORATIONS)
.filter((e) => e.topic === topicId && e.image)
.map((e) => e.image!)
- return images.length > 0 ? images[Math.floor(Math.random() * images.length)] : undefined
+ return pickRandom(images)
}
export function getTopicExplorationIds(topicId: string): string[] {
diff --git a/src/libs/__tests__/docsUrls.spec.ts b/src/libs/__tests__/docsUrls.spec.ts
new file mode 100644
index 0000000..5b190ad
--- /dev/null
+++ b/src/libs/__tests__/docsUrls.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+
+import { DOCS_ADD_EXPLORATION, DOCS_HOME, docsPage } from '../docsUrls'
+
+describe('docsUrls', () => {
+ it('builds home URL with index.html', () => {
+ expect(DOCS_HOME).toBe('https://docs.feelyourprotocol.org/index.html')
+ expect(docsPage()).toBe(DOCS_HOME)
+ })
+
+ it('builds page URLs with .html suffix', () => {
+ expect(DOCS_ADD_EXPLORATION).toBe(
+ 'https://docs.feelyourprotocol.org/contributing/adding-an-exploration.html',
+ )
+ expect(docsPage('guide/architecture', 'topics')).toBe(
+ 'https://docs.feelyourprotocol.org/guide/architecture.html#topics',
+ )
+ })
+})
diff --git a/src/libs/docsUrls.ts b/src/libs/docsUrls.ts
new file mode 100644
index 0000000..cf2611a
--- /dev/null
+++ b/src/libs/docsUrls.ts
@@ -0,0 +1,12 @@
+/** Production docs URLs (static host serves VitePress output as `.html` files). */
+export const DOCS_ORIGIN = 'https://docs.feelyourprotocol.org'
+
+export function docsPage(path = '', hash?: string): string {
+ const slug = path.replace(/^\//, '').replace(/\.html$/, '')
+ const fragment = hash ? `#${hash.replace(/^#/, '')}` : ''
+ if (!slug) return `${DOCS_ORIGIN}/index.html${fragment}`
+ return `${DOCS_ORIGIN}/${slug}.html${fragment}`
+}
+
+export const DOCS_HOME = docsPage()
+export const DOCS_ADD_EXPLORATION = docsPage('contributing/adding-an-exploration')
diff --git a/src/router/index.ts b/src/router/index.ts
index d386922..80173d8 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -45,7 +45,16 @@ function loadRoutes() {
})
}
- return [...homeRs, ...explorationRs, ...topicRs]
+ return [
+ ...homeRs,
+ ...explorationRs,
+ ...topicRs,
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'not-found',
+ component: baseViews['../views/NotFoundView.vue'],
+ },
+ ]
}
const router = createRouter({
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index 434847a..29340c9 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -2,6 +2,7 @@
import ExplorationC from '@/explorations/ExplorationC.vue'
import { EXPLORATIONS, getRandomTopicExplorationImage } from '@/explorations/REGISTRY'
import { TOPICS } from '@/explorations/TOPICS'
+import { DOCS_HOME } from '@/libs/docsUrls'
import TagCloudView from './TagCloudView.vue'
import TimelineNaviView from './TimelineNaviView.vue'
@@ -58,7 +59,7 @@ for (const topicId of activeTopicIds) {
Want to contribute?
Check the docs
+import { getRandomExplorationWithImage } from '@/explorations/REGISTRY'
+import { TOPIC_COLORS, topicCSSVars, TOPICS } from '@/explorations/TOPICS'
+import { DOCS_ADD_EXPLORATION } from '@/libs/docsUrls'
+
+const pick = getRandomExplorationWithImage()
+const topic = pick ? TOPICS[pick.topic] : undefined
+const cssVars = topic ? topicCSSVars(topic.color) : undefined
+const borderCard = topic ? TOPIC_COLORS[topic.color].classes.borderCard : 'border border-slate-200'
+
+function getImageUrl(image: string): string {
+ return image.includes('/')
+ ? image
+ : new URL(`../assets/imgs/dancers/${image}`, import.meta.url).href
+}
+
+
+
+
+
+
+ ◆
+ 404
+
+
+
+ This path isn't in the registry.
+
+
+
+
+
+ No exploration lives at this URL — yet. Uncharted protocol territory is kind of our
+ thing.
+
+
+ Ready to make it yours?
+ Add an exploration
+ — the docs walk you through it.
+
+
+
+
← Home
+
+ Feel something real instead →
+
+
Contributor guide
+
+
+
+
+
+
+ Random pick — {{ pick.title }}
+
+
+
+
+
+
diff --git a/src/views/__tests__/HomeView.spec.ts b/src/views/__tests__/HomeView.spec.ts
index a1c7819..0932bc2 100644
--- a/src/views/__tests__/HomeView.spec.ts
+++ b/src/views/__tests__/HomeView.spec.ts
@@ -5,6 +5,7 @@ import { mount, RouterLinkStub } from '@vue/test-utils'
import { EXPLORATIONS } from '@/explorations/REGISTRY'
import { Tag } from '@/explorations/TAGS'
import { TOPICS } from '@/explorations/TOPICS'
+import { DOCS_HOME } from '@/libs/docsUrls'
import HomeView from '../HomeView.vue'
@@ -58,7 +59,7 @@ describe('HomeView', () => {
})
it('has contributor docs link pointing to docs site', () => {
- const link = wrapper.find('a[href="https://docs.feelyourprotocol.org"]')
+ const link = wrapper.find(`a[href="${DOCS_HOME}"]`)
expect(link.exists()).toBe(true)
expect(link.attributes('target')).toBe('_blank')
})
diff --git a/src/views/__tests__/NotFoundView.spec.ts b/src/views/__tests__/NotFoundView.spec.ts
new file mode 100644
index 0000000..b8e0f67
--- /dev/null
+++ b/src/views/__tests__/NotFoundView.spec.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import { mount } from '@vue/test-utils'
+
+import { EXPLORATIONS } from '@/explorations/REGISTRY'
+import { DOCS_ADD_EXPLORATION } from '@/libs/docsUrls'
+import NotFoundView from '@/views/NotFoundView.vue'
+
+function makeRouter() {
+ const explorationRoutes = Object.entries(EXPLORATIONS).map(([id, e]) => ({
+ path: e.path,
+ name: id,
+ component: { template: '
' },
+ }))
+
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/', component: { template: '' } }, ...explorationRoutes],
+ })
+}
+
+describe('NotFoundView', () => {
+ it('shows a 404 headline and contributor call-to-action', () => {
+ const wrapper = mount(NotFoundView, {
+ global: { plugins: [makeRouter()] },
+ })
+
+ expect(wrapper.text()).toContain('404')
+ expect(wrapper.text()).toContain("This path isn't in the registry.")
+ expect(wrapper.text()).toContain('Add an exploration')
+ expect(wrapper.text()).toContain('← Home')
+ })
+
+ it('links to the contributor guide', () => {
+ const wrapper = mount(NotFoundView, {
+ global: { plugins: [makeRouter()] },
+ })
+
+ const docsLinks = wrapper.findAll(`a[href="${DOCS_ADD_EXPLORATION}"]`)
+ expect(docsLinks.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('shows a random exploration image when available', () => {
+ const wrapper = mount(NotFoundView, {
+ global: { plugins: [makeRouter()] },
+ })
+
+ const img = wrapper.find('img')
+ expect(img.exists()).toBe(true)
+ expect(wrapper.text()).toContain('Random pick')
+ })
+})
From d91cdad05ddb67d0762a9d7f146d6e876c4bbd42 Mon Sep 17 00:00:00 2001
From: Holger Drewes <931137+holgerd77@users.noreply.github.com>
Date: Mon, 15 Jun 2026 13:23:19 +0200
Subject: [PATCH 3/3] 404 page + sitemap + robots.txt generation
---
package.json | 5 ++-
scripts/generate-spa-fallbacks.ts | 38 ++++++++++++++++++
scripts/tsconfig.json | 9 +++++
src/libs/__tests__/spaRoutes.spec.ts | 59 ++++++++++++++++++++++++++++
src/libs/spaRoutes.ts | 58 +++++++++++++++++++++++++++
tsconfig.json | 3 ++
6 files changed, 170 insertions(+), 2 deletions(-)
create mode 100644 scripts/generate-spa-fallbacks.ts
create mode 100644 scripts/tsconfig.json
create mode 100644 src/libs/__tests__/spaRoutes.spec.ts
create mode 100644 src/libs/spaRoutes.ts
diff --git a/package.json b/package.json
index ca8dfe9..4a352a3 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,9 @@
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
"type-check": "vue-tsc --build",
"website:build": "run-p type-check \"website:build-only {@}\" --",
- "website:build-only": "vite build",
- "website:build:deploy": "vite build",
+ "website:build-only": "vite build && npm run generate:spa-fallbacks",
+ "website:build:deploy": "vite build && npm run generate:spa-fallbacks",
+ "generate:spa-fallbacks": "vite-node scripts/generate-spa-fallbacks.ts",
"website:preview": "vite preview --outDir dist/website",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
diff --git a/scripts/generate-spa-fallbacks.ts b/scripts/generate-spa-fallbacks.ts
new file mode 100644
index 0000000..eed824e
--- /dev/null
+++ b/scripts/generate-spa-fallbacks.ts
@@ -0,0 +1,38 @@
+/**
+ * Post-build step (`npm run generate:spa-fallbacks`, after `vite build`).
+ *
+ * nginx serves static files with `try_files $uri $uri/ =404` (no blanket index.html
+ * fallback). This script materializes what nginx needs:
+ *
+ * - `scaling/index.html`, … — one copy per valid SPA route so deep links return 200
+ * - `404.html` — same app shell; Vue Router shows NotFoundView for unknown paths
+ * - `sitemap.xml` and `robots.txt` — canonical URLs from `src/libs/spaRoutes.ts`
+ *
+ * Route logic lives in spaRoutes.ts (testable, no filesystem I/O); this file only writes files.
+ */
+import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import {
+ generateRobotsTxt,
+ generateSitemapXml,
+ getSpaFallbackDirectories,
+} from '../src/libs/spaRoutes'
+
+const outDir = join(dirname(fileURLToPath(import.meta.url)), '../dist/website')
+const indexPath = join(outDir, 'index.html')
+
+for (const dir of getSpaFallbackDirectories()) {
+ const targetDir = join(outDir, dir)
+ mkdirSync(targetDir, { recursive: true })
+ copyFileSync(indexPath, join(targetDir, 'index.html'))
+}
+
+copyFileSync(indexPath, join(outDir, '404.html'))
+writeFileSync(join(outDir, 'sitemap.xml'), generateSitemapXml())
+writeFileSync(join(outDir, 'robots.txt'), generateRobotsTxt())
+
+console.log(
+ `Wrote ${getSpaFallbackDirectories().length} SPA fallbacks, 404.html, sitemap.xml, robots.txt`,
+)
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 0000000..925672c
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.node.json",
+ "include": ["./**/*.ts", "../env.d.ts"],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["../src/*"]
+ }
+ }
+}
diff --git a/src/libs/__tests__/spaRoutes.spec.ts b/src/libs/__tests__/spaRoutes.spec.ts
new file mode 100644
index 0000000..2e6508b
--- /dev/null
+++ b/src/libs/__tests__/spaRoutes.spec.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+
+import { EXPLORATIONS } from '@/explorations/REGISTRY'
+import { TOPICS } from '@/explorations/TOPICS'
+
+import {
+ generateRobotsTxt,
+ generateSitemapXml,
+ getSitemapUrls,
+ getSpaFallbackDirectories,
+ getValidSpaPaths,
+ SITE_ORIGIN,
+} from '../spaRoutes'
+
+describe('spaRoutes', () => {
+ it('lists all router paths except the catch-all 404 route', () => {
+ const paths = getValidSpaPaths()
+
+ expect(paths).toContain('/')
+ expect(paths).toContain('/imprint')
+ expect(paths).toContain('/all')
+
+ for (const topic of Object.values(TOPICS)) {
+ expect(paths).toContain(topic.path)
+ }
+ for (const exploration of Object.values(EXPLORATIONS)) {
+ expect(paths).toContain(exploration.path)
+ }
+ })
+
+ it('derives fallback directories for nginx try_files', () => {
+ const dirs = getSpaFallbackDirectories()
+
+ expect(dirs).not.toContain('')
+ expect(dirs).toContain('imprint')
+ expect(dirs).toContain('scaling')
+ expect(dirs).toContain(Object.values(EXPLORATIONS)[0]!.path.replace(/^\//, ''))
+ })
+
+ it('builds sitemap URLs for all valid SPA paths', () => {
+ const urls = getSitemapUrls()
+
+ expect(urls).toContain(`${SITE_ORIGIN}/`)
+ expect(urls).toContain(`${SITE_ORIGIN}/imprint`)
+ expect(urls.length).toBe(getValidSpaPaths().length)
+ expect(urls.every((url) => url.startsWith(SITE_ORIGIN))).toBe(true)
+ })
+
+ it('generates sitemap.xml and robots.txt', () => {
+ const xml = generateSitemapXml()
+ expect(xml).toContain('
+ path === '/' ? `${SITE_ORIGIN}/` : `${SITE_ORIGIN}${path}`,
+ )
+}
+
+export function generateSitemapXml(): string {
+ const urls = getSitemapUrls()
+ const entries = urls.map((loc) => ` ${loc}`).join('\n')
+
+ return [
+ '',
+ '',
+ entries,
+ '',
+ '',
+ ].join('\n')
+}
+
+export function generateRobotsTxt(): string {
+ return ['User-agent: *', 'Allow: /', '', `Sitemap: ${SITE_ORIGIN}/sitemap.xml`, ''].join('\n')
+}
+
+/** Directory names under dist/website/ that receive a copied index.html (all except `/`). */
+export function getSpaFallbackDirectories(): string[] {
+ return getValidSpaPaths()
+ .filter((path) => path !== '/')
+ .map((path) => path.replace(/^\//, ''))
+}
diff --git a/tsconfig.json b/tsconfig.json
index 5304731..501c04e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,6 +9,9 @@
},
{
"path": "./tsconfig.vitest.json"
+ },
+ {
+ "path": "./scripts/tsconfig.json"
}
],
"compilerOptions": {