mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-10-03 04:16:44 -05:00
Merge pull request #127 from misenhower/codex/harmony-404
Add Harmony 404 page with proper missing-page responses
This commit is contained in:
17
404.html
Normal file
17
404.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#5432bb">
|
||||
<title>404 — Splatoon3.ink</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript><h1>404 — Page not found</h1><p><a href="/">Back home</a></p></noscript>
|
||||
<script type="module" src="/src/not-found.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
src/assets/img/harmony.png
Normal file
BIN
src/assets/img/harmony.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
5
src/not-found.js
Normal file
5
src/not-found.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import NotFoundView from './views/NotFoundView.vue';
|
||||
import './assets/css/base.css';
|
||||
|
||||
createApp(NotFoundView).mount('#app');
|
||||
@@ -7,6 +7,7 @@ import AboutView from '@/views/AboutView.vue';
|
||||
import SplatfestsView from '@/views/SplatfestsView.vue';
|
||||
import ChallengesView from '@/views/ChallengesView.vue';
|
||||
import SocialsView from '@/views/SocialsView.vue';
|
||||
import NotFoundView from '@/views/NotFoundView.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -18,6 +19,7 @@ const router = createRouter({
|
||||
ChallengesView,
|
||||
SocialsView,
|
||||
AboutView,
|
||||
NotFoundView,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
32
src/router/redirects.mjs
Normal file
32
src/router/redirects.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Generate Cloudflare Pages' _redirects file from the Vue router at build time.
|
||||
// A top-level 404.html disables Pages' automatic SPA fallback, so known page
|
||||
// URLs need explicit 200 rewrites to the root HTML entry for direct visits and
|
||||
// refreshes. Vue Router then renders the page for the original URL.
|
||||
//
|
||||
// Named redirects (such as /faq to /about) and trailing-slash URLs use 301s.
|
||||
// The root is served normally; parameterized and catch-all routes are excluded
|
||||
// so unknown paths reach 404.html with a real 404 status. New static routes are
|
||||
// included automatically, keeping the hosting rules in sync with the router.
|
||||
export function createRedirects(routes) {
|
||||
return routes
|
||||
.filter(route => route.path !== '/' && !/[:*]/.test(route.path))
|
||||
.flatMap(route => {
|
||||
let destination = '/';
|
||||
let status = 200;
|
||||
|
||||
if (route.redirect) {
|
||||
destination = routes.find(target => target.name === route.redirect.name)?.path;
|
||||
status = 301;
|
||||
|
||||
if (!destination) {
|
||||
throw new Error(`Unknown redirect target for ${route.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
`${route.path} ${destination} ${status}`,
|
||||
`${route.path}/ ${route.redirect ? destination : route.path} 301`,
|
||||
];
|
||||
})
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
34
src/router/redirects.test.mjs
Normal file
34
src/router/redirects.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createRoutes } from './routes.mjs';
|
||||
import { createRedirects } from './redirects.mjs';
|
||||
|
||||
describe('Pages route rewrites', () => {
|
||||
it('serves known routes and their trailing-slash forms without a catch-all rewrite', () => {
|
||||
const rules = createRedirects(createRoutes()).trim().split('\n');
|
||||
|
||||
expect(rules).toContain('/gear / 200');
|
||||
expect(rules).toContain('/gear/ /gear 301');
|
||||
expect(rules.some(rule => /[:*]/.test(rule))).toBe(false);
|
||||
expect(rules.some(rule => rule.startsWith('/screenshots'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the FAQ redirect from the named router destination', () => {
|
||||
const routes = createRoutes();
|
||||
routes.find(route => route.name === 'about').path = '/about-us';
|
||||
|
||||
expect(createRedirects(routes)).toContain('/faq /about-us 301\n');
|
||||
expect(createRedirects(routes)).toContain('/faq/ /about-us 301\n');
|
||||
});
|
||||
|
||||
it('automatically includes new static pages', () => {
|
||||
const routes = [...createRoutes(), { path: '/new-page', name: 'new-page' }];
|
||||
|
||||
expect(createRedirects(routes)).toContain('/new-page / 200\n');
|
||||
});
|
||||
|
||||
it('fails the build for an unresolved named redirect', () => {
|
||||
expect(() => createRedirects([
|
||||
{ path: '/old', redirect: { name: 'missing' } },
|
||||
])).toThrow('Unknown redirect target for /old');
|
||||
});
|
||||
});
|
||||
@@ -40,5 +40,10 @@ export function createRoutes(views = {}) {
|
||||
path: '/faq',
|
||||
redirect: { name: 'about' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: views.NotFoundView,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
293
src/views/NotFoundView.vue
Normal file
293
src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<main class="not-found">
|
||||
<section class="portrait-card" aria-labelledby="error-title">
|
||||
<div class="portrait-orbit" aria-hidden="true" />
|
||||
<div class="portrait-dots" aria-hidden="true" />
|
||||
<div class="portrait-copy">
|
||||
<p class="eyebrow">
|
||||
Page not found
|
||||
</p>
|
||||
<h1 id="error-title">
|
||||
404
|
||||
</h1>
|
||||
<h2>...Oh, hey.</h2>
|
||||
<p class="error-description">
|
||||
That page? Uh...<br />I don’t think we have it.
|
||||
</p>
|
||||
<a href="/" class="home-button">
|
||||
<span class="squid" aria-hidden="true"></span>
|
||||
Back home
|
||||
</a>
|
||||
</div>
|
||||
<img
|
||||
src="@/assets/img/harmony.png"
|
||||
alt="Harmony staring blankly into space"
|
||||
class="portrait-art"
|
||||
width="397"
|
||||
height="776"
|
||||
/>
|
||||
<div class="portrait-bubble" aria-hidden="true">
|
||||
...
|
||||
</div>
|
||||
<img src="@/assets/img/tape/tape-1.png" alt="" class="portrait-tape" />
|
||||
<span class="portrait-tag" aria-hidden="true">NOT FOUND / 404</span>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
let previousTitle;
|
||||
|
||||
onMounted(() => {
|
||||
previousTitle = document.title;
|
||||
document.title = '404 — Splatoon3.ink';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.title = previousTitle;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100svh;
|
||||
padding: 90px 28px 60px;
|
||||
}
|
||||
|
||||
.portrait-card {
|
||||
position: relative;
|
||||
width: min(704px, 100%);
|
||||
border-radius: 38px 65px 40px 52px;
|
||||
background: #5432bb url('@/assets/img/octoarrow-transparent.png') center / 240px;
|
||||
box-shadow: 0 20px 0 #0003, 0 26px 55px #0004;
|
||||
transform: rotate(-1.3deg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.portrait-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid #bc92ff25;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portrait-orbit,
|
||||
.portrait-dots {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portrait-orbit {
|
||||
left: 38px;
|
||||
top: 45px;
|
||||
width: 305px;
|
||||
height: 325px;
|
||||
background: #b990f5;
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
|
||||
.portrait-dots {
|
||||
left: 27px;
|
||||
top: 29px;
|
||||
width: 330px;
|
||||
height: 350px;
|
||||
border: 2px dashed #dbc5ff75;
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
|
||||
.portrait-art {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 58px;
|
||||
bottom: -12px;
|
||||
height: 473px;
|
||||
width: auto;
|
||||
filter: drop-shadow(12px 13px 0 #28175635);
|
||||
transform: rotate(1.3deg);
|
||||
}
|
||||
|
||||
.portrait-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
padding: 34px 36px 40px 374px;
|
||||
transform: rotate(1.3deg);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #d4c1ff;
|
||||
font: 12px/1.5 var(--font-family-s2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .13em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 8px -5px;
|
||||
color: #f1edff;
|
||||
font: 124px/1 var(--font-family-s1);
|
||||
text-shadow: 5px 7px #2c176d;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #eaff3d;
|
||||
font: 37px/1.2 var(--font-family-s2);
|
||||
}
|
||||
|
||||
.error-description {
|
||||
margin-top: 12px;
|
||||
color: #e1d8fa;
|
||||
font: 17px/1.6 var(--font-family-s2);
|
||||
}
|
||||
|
||||
.home-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 25px;
|
||||
padding: 12px 23px;
|
||||
border-radius: 999px;
|
||||
background: #eaff3d;
|
||||
color: #29232e;
|
||||
font: 17px/1.4 var(--font-family-s2);
|
||||
box-shadow: 0 5px 0 #28175670;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.home-button:hover {
|
||||
background: #f5ff9b;
|
||||
}
|
||||
|
||||
.home-button:focus-visible {
|
||||
outline: 3px solid #fff;
|
||||
outline-offset: 5px;
|
||||
}
|
||||
|
||||
.squid {
|
||||
display: inline-block;
|
||||
transform: rotate(25deg);
|
||||
}
|
||||
|
||||
.portrait-bubble {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: -42px;
|
||||
left: 288px;
|
||||
padding: 1px 24px 17px;
|
||||
border-radius: 50%;
|
||||
background: #eaff3d;
|
||||
color: #382176;
|
||||
font: 44px/1.2 var(--font-family-s2);
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
|
||||
.portrait-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 18px;
|
||||
border-top: 20px solid #eaff3d;
|
||||
border-right: 18px solid transparent;
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
|
||||
.portrait-tape {
|
||||
position: absolute;
|
||||
right: 34px;
|
||||
top: -19px;
|
||||
width: 145px;
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
|
||||
.portrait-tag {
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 314px;
|
||||
padding: 5px 12px;
|
||||
background: #242328;
|
||||
color: #b5a4d7;
|
||||
font: 9px/1.4 monospace;
|
||||
letter-spacing: .12em;
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.portrait-card {
|
||||
width: min(360px, 100%);
|
||||
border-radius: 30px 40px 30px 35px;
|
||||
}
|
||||
|
||||
.portrait-orbit {
|
||||
width: 233px;
|
||||
height: 233px;
|
||||
left: calc(50% - 116px);
|
||||
top: 26px;
|
||||
}
|
||||
|
||||
.portrait-dots {
|
||||
width: 255px;
|
||||
height: 255px;
|
||||
left: calc(50% - 127px);
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.portrait-art {
|
||||
left: calc(50% - 116px);
|
||||
top: -25px;
|
||||
bottom: auto;
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.portrait-copy {
|
||||
padding: 274px 16px 29px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
position: absolute;
|
||||
top: 84px;
|
||||
left: 45%;
|
||||
font-size: clamp(64px, 20vw, 83px);
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 9px;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-button {
|
||||
margin-top: 22px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.portrait-bubble {
|
||||
top: 7px;
|
||||
left: auto;
|
||||
right: 24px;
|
||||
font-size: 29px;
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.portrait-tape {
|
||||
width: 105px;
|
||||
top: -14px;
|
||||
right: auto;
|
||||
left: 10px;
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
|
||||
.portrait-tag {
|
||||
left: auto;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
import { createRoutes } from './src/router/routes.mjs';
|
||||
import { createRedirects } from './src/router/redirects.mjs';
|
||||
|
||||
const redirectToDist = [
|
||||
'/assets/splatnet/',
|
||||
@@ -16,6 +17,7 @@ const redirectToDist = [
|
||||
export default defineConfig({
|
||||
input: {
|
||||
main: resolve(import.meta.dirname, 'index.html'),
|
||||
notFound: resolve(import.meta.dirname, '404.html'),
|
||||
screenshots: resolve(import.meta.dirname, 'screenshots/index.html'),
|
||||
},
|
||||
plugins: [
|
||||
@@ -25,9 +27,10 @@ export default defineConfig({
|
||||
include: resolve(import.meta.dirname, './src/assets/i18n/*.json'),
|
||||
}),
|
||||
{
|
||||
name: 'sitemap',
|
||||
name: 'page-metadata',
|
||||
generateBundle() {
|
||||
const urls = createRoutes()
|
||||
const routes = createRoutes();
|
||||
const urls = routes
|
||||
.filter(route => !route.redirect && !route.path.includes(':') && route.meta?.sitemap !== false)
|
||||
.map(route => {
|
||||
const url = new URL(route.path, 'https://splatoon3.ink').href
|
||||
@@ -48,6 +51,12 @@ export default defineConfig({
|
||||
'</urlset>\n',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: '_redirects',
|
||||
source: createRedirects(routes),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user