(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/__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/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__/AppLayout.spec.ts b/src/views/__tests__/AppLayout.spec.ts
index 3b63340..82a456b 100644
--- a/src/views/__tests__/AppLayout.spec.ts
+++ b/src/views/__tests__/AppLayout.spec.ts
@@ -104,5 +104,11 @@ describe('App layout', () => {
const ghLink = wrapper.find('footer a[href="https://github.com/feelyourprotocol/website"]')
expect(ghLink.exists()).toBe(true)
})
+
+ it('has X link', async () => {
+ const wrapper = await mountApp(makeRouter())
+ const xLink = wrapper.find('footer a[href="https://x.com/FeelEthereum"]')
+ expect(xLink.exists()).toBe(true)
+ })
})
})
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')
+ })
+})
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": {