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
12 changes: 6 additions & 6 deletions cypress/e2e/explorations.cy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
describe('EIP-7883 ModExp', () => {
it('loads and displays exploration content', () => {
cy.visit('/eip-7883-modexp-gas-cost-increase')
cy.contains('h1', 'Feel Your Protocol')
cy.contains('h3', 'ModExp')
cy.get('header').contains('Feel Your Protocol')
cy.contains('h1', 'ModExp')
cy.get('#eip-7883-c', { timeout: 10000 }).should('exist')
})

Expand All @@ -22,8 +22,8 @@ describe('EIP-7883 ModExp', () => {
describe('EIP-7594 PeerDAS', () => {
it('loads and displays exploration content', () => {
cy.visit('/eip-7594-peerdas-data-availability-sampling')
cy.contains('h1', 'Feel Your Protocol')
cy.contains('h3', 'Peer Data Availability Sampling')
cy.get('header').contains('Feel Your Protocol')
cy.contains('h1', 'Peer Data Availability Sampling')
cy.get('#eip-7594-c', { timeout: 10000 }).should('exist')
})

Expand All @@ -42,8 +42,8 @@ describe('EIP-7594 PeerDAS', () => {
describe('EIP-7951 secp256r1', () => {
it('loads and displays exploration content', () => {
cy.visit('/eip-7951-secp256r1-precompile')
cy.contains('h1', 'Feel Your Protocol')
cy.contains('h3', 'secp256r1 Precompile Support')
cy.get('header').contains('Feel Your Protocol')
cy.contains('h1', 'secp256r1 Precompile Support')
cy.get('#eip-7951-c', { timeout: 10000 }).should('exist')
})

Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/sites.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('Navigation', () => {
cy.get('footer').contains('Imprint').click()
cy.url().should('include', '/imprint')

cy.contains('h1', 'Feel Your Protocol').click()
cy.get('header').contains('Feel Your Protocol').click()
cy.url().should('eq', Cypress.config().baseUrl + '/')
})
})
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/src/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Interactive explorations of upcoming Ethereum protocol changes, powered by real EVM and cryptography libraries in the browser.">
<title>Feel Your Protocol</title>
</head>
<body class="container mx-auto p-2">
Expand Down
67 changes: 56 additions & 11 deletions scripts/generate-spa-fallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,80 @@
* 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`
* - Per-route `index.html` with injected title, meta, canonical, Open Graph, JSON-LD
* - `404.html` — same app shell with noindex meta
* - `sitemap.xml` and `robots.txt`
*
* Route logic lives in spaRoutes.ts (testable, no filesystem I/O); this file only writes files.
* Route logic lives in `pageSeo.ts` (testable); this file handles filesystem writes.
*/
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { EXPLORATIONS } from '../src/explorations/REGISTRY'
import { TOPICS } from '../src/explorations/TOPICS'
import {
generateRobotsTxt,
generateSitemapXml,
getPageSeoForPath,
getSpaFallbackDirectories,
} from '../src/libs/spaRoutes'
getValidSpaPaths,
injectSeoIntoHtml,
} from '../src/libs/pageSeo'

const outDir = join(dirname(fileURLToPath(import.meta.url)), '../dist/website')
const scriptDir = dirname(fileURLToPath(import.meta.url))
const websiteRoot = join(scriptDir, '..')
const outDir = join(websiteRoot, 'dist/website')
const indexPath = join(outDir, 'index.html')

function toLastmod(date: Date): string {
return date.toISOString().slice(0, 10)
}

function lastmodForPath(path: string): string {
if (path === '/' || path === '/all') {
return toLastmod(statSync(join(websiteRoot, 'package.json')).mtime)
}

if (path === '/imprint') {
return toLastmod(statSync(join(websiteRoot, 'src/views/ImprintView.vue')).mtime)
}

const exploration = Object.values(EXPLORATIONS).find((entry) => entry.path === path)
if (exploration) {
return toLastmod(statSync(join(websiteRoot, 'src/explorations', exploration.id, 'info.ts')).mtime)
}

const topic = Object.values(TOPICS).find((entry) => entry.path === path)
if (topic && topic.explorations.length > 0) {
const mtimes = topic.explorations.map((id) =>
statSync(join(websiteRoot, 'src/explorations', id, 'info.ts')).mtimeMs,
)
return toLastmod(new Date(Math.max(...mtimes)))
}

return toLastmod(statSync(join(websiteRoot, 'package.json')).mtime)
}

const shellHtml = readFileSync(indexPath, 'utf8')
const lastmodByPath = Object.fromEntries(getValidSpaPaths().map((path) => [path, lastmodForPath(path)]))

writeFileSync(indexPath, injectSeoIntoHtml(shellHtml, getPageSeoForPath('/')))

for (const dir of getSpaFallbackDirectories()) {
const path = `/${dir}`
const targetDir = join(outDir, dir)
mkdirSync(targetDir, { recursive: true })
copyFileSync(indexPath, join(targetDir, 'index.html'))
writeFileSync(join(targetDir, 'index.html'), injectSeoIntoHtml(shellHtml, getPageSeoForPath(path)))
}

copyFileSync(indexPath, join(outDir, '404.html'))
writeFileSync(join(outDir, 'sitemap.xml'), generateSitemapXml())
writeFileSync(
join(outDir, '404.html'),
injectSeoIntoHtml(shellHtml, { ...getPageSeoForPath('/404-not-found'), noindex: true }),
)
writeFileSync(join(outDir, 'sitemap.xml'), generateSitemapXml(lastmodByPath))
writeFileSync(join(outDir, 'robots.txt'), generateRobotsTxt())

console.log(
`Wrote ${getSpaFallbackDirectories().length} SPA fallbacks, 404.html, sitemap.xml, robots.txt`,
`Wrote ${getSpaFallbackDirectories().length + 1} SEO HTML shells, 404.html, sitemap.xml, robots.txt`,
)
4 changes: 2 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ watch(
<template>
<header class="mt-3 mb-8">
<div class="grid grid-cols-2 mb-3">
<h1>
<div class="site-title">
<RouterLink
to="/"
class="text-2xl md:text-4xl font-bold tracking-wider whitespace-nowrap bg-gradient-to-r from-purple-600 to-cyan-500 bg-clip-text text-transparent"
>Feel Your Protocol</RouterLink
>
</h1>
</div>
<nav class="font-mono text-sm text-right flex justify-end items-center">
<Listbox v-model="selectedRoute" @update:model-value="navigate">
<div class="relative inline-block">
Expand Down
29 changes: 29 additions & 0 deletions src/components/BreadcrumbNav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { BreadcrumbItem } from '@/libs/pageSeo'

defineProps<{
items: BreadcrumbItem[]
}>()
</script>

<template>
<nav aria-label="Breadcrumb" class="font-mono text-xs text-slate-400 mb-4">
<ol class="flex flex-wrap items-center gap-x-1.5 gap-y-1 list-none p-0 m-0">
<li
v-for="(item, index) in items"
:key="`${item.label}-${index}`"
class="inline-flex items-center gap-x-1.5"
>
<span v-if="index > 0" aria-hidden="true">›</span>
<RouterLink
v-if="item.to && index < items.length - 1"
:to="item.to"
class="hover:underline text-slate-500"
>
{{ item.label }}
</RouterLink>
<span v-else aria-current="page" class="text-slate-500">{{ item.label }}</span>
</li>
</ol>
</nav>
</template>
7 changes: 6 additions & 1 deletion src/eComponents/bytecodeStepperEC/BytecodeStepperEC.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ function formatStackWord(word: bigint): string {

<template>
<div class="flex flex-col gap-4">
<ExplorationC :explorationId="config.explorationId" :exploration="exploration" :topic="topic">
<ExplorationC
asPageTitle
:explorationId="config.explorationId"
:exploration="exploration"
:topic="topic"
>
<template #content>
<div>
<ExamplesUIC v-model="example" :examples="examples" :change="onExampleChange" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ await init()

<template>
<ExplorationC
asPageTitle
:explorationId="config.explorationId"
:exploration="exploration"
:topic="topic"
Expand Down
38 changes: 31 additions & 7 deletions src/explorations/ExplorationC.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@
import { ArrowTopRightOnSquareIcon, ShareIcon } from '@heroicons/vue/24/solid'

import ButtonUIC from '@/eComponents/ui/ButtonUIC.vue'
import { formatEipSpecLabel } from '@/libs/pageSeo'

import type { Exploration } from './REGISTRY'
import { type Topic, topicCSSVars } from './TOPICS'

const props = defineProps<{
explorationId: string
exploration: Exploration
topic: Topic
shareURL?: () => void
}>()
const props = withDefaults(
defineProps<{
explorationId: string
exploration: Exploration
topic: Topic
shareURL?: () => void
asPageTitle?: boolean
}>(),
{
asPageTitle: false,
},
)

const cssVars = topicCSSVars(props.topic.color)
const eipLabel = formatEipSpecLabel(props.explorationId)
</script>

<template>
<div :id="explorationId + '-c'" :style="cssVars" class="exploration-c">
<div class="grid grid-cols-4 mb-2 items-center">
<h3 class="font-bold text-lg tracking-tight col-span-3 e-text">{{ exploration.title }}</h3>
<component
:is="asPageTitle ? 'h1' : 'h3'"
class="font-bold text-lg tracking-tight col-span-3 e-text"
>
{{ exploration.title }}
</component>
<div class="flex justify-end items-center gap-1">
<a v-if="shareURL" href="#" @click.prevent="shareURL" class="share-url-button">
<ButtonUIC :icon="ShareIcon" tooltip="Open Shareable URL" />
Expand All @@ -36,6 +49,17 @@ const cssVars = topicCSSVars(props.topic.color)
<div class="font-mono text-xs leading-relaxed mb-5 text-slate-600">
<p v-html="exploration.introText"></p>
<p class="mt-3" v-html="exploration.usageText"></p>
<p v-if="asPageTitle" class="mt-3">
Official spec:
<a
:href="exploration.infoURL"
target="_blank"
rel="noopener"
class="e-text underline underline-offset-2 hover:no-underline"
>
{{ eipLabel }} on eips.ethereum.org
</a>
</p>
</div>

<slot name="content"></slot>
Expand Down
2 changes: 1 addition & 1 deletion src/explorations/eip-7594/MyC.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ await init()
</script>

<template>
<ExplorationC explorationId="eip-7594" :exploration="exploration" :topic="topic">
<ExplorationC asPageTitle explorationId="eip-7594" :exploration="exploration" :topic="topic">
<template #content>
<div class="mt-3 text-right">
<ActionButtonUIC
Expand Down
44 changes: 44 additions & 0 deletions src/libs/__tests__/applyPageSeo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest'

import { EXPLORATIONS } from '@/explorations/REGISTRY'

import { applyPageSeo } from '../applyPageSeo'
import { getPageSeoForPath } from '../pageSeo'

describe('applyPageSeo', () => {
afterEach(() => {
document.head.innerHTML = ''
document.title = ''
})

it('updates document title, description, canonical, and JSON-LD', () => {
const exploration = Object.values(EXPLORATIONS)[0]!
const seo = getPageSeoForPath(exploration.path)

applyPageSeo(seo)

expect(document.title).toBe(seo.title)
expect(document.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
seo.description,
)
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
seo.canonicalUrl,
)
expect(document.getElementById('page-seo-jsonld')?.textContent).toContain('BreadcrumbList')
})

it('sets robots noindex for filtered routes', () => {
applyPageSeo(getPageSeoForPath('/all'))

expect(document.querySelector('meta[name="robots"]')).toBeNull()

applyPageSeo({
...getPageSeoForPath('/all'),
noindex: true,
})

expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'noindex, follow',
)
})
})
Loading