diff --git a/404.html b/404.html
new file mode 100644
index 0000000..6307528
--- /dev/null
+++ b/404.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+ 404 — Splatoon3.ink
+
+
+
+
+
+
+
diff --git a/src/assets/img/harmony.png b/src/assets/img/harmony.png
new file mode 100644
index 0000000..3b4a992
Binary files /dev/null and b/src/assets/img/harmony.png differ
diff --git a/src/not-found.js b/src/not-found.js
new file mode 100644
index 0000000..9cc6abd
--- /dev/null
+++ b/src/not-found.js
@@ -0,0 +1,5 @@
+import { createApp } from 'vue';
+import NotFoundView from './views/NotFoundView.vue';
+import './assets/css/base.css';
+
+createApp(NotFoundView).mount('#app');
diff --git a/src/router/index.js b/src/router/index.js
index 2406e83..9620a9b 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -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,
}),
});
diff --git a/src/router/redirects.mjs b/src/router/redirects.mjs
new file mode 100644
index 0000000..e3b3cdc
--- /dev/null
+++ b/src/router/redirects.mjs
@@ -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';
+}
diff --git a/src/router/redirects.test.mjs b/src/router/redirects.test.mjs
new file mode 100644
index 0000000..b5db195
--- /dev/null
+++ b/src/router/redirects.test.mjs
@@ -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');
+ });
+});
diff --git a/src/router/routes.mjs b/src/router/routes.mjs
index 3f52e62..53390df 100644
--- a/src/router/routes.mjs
+++ b/src/router/routes.mjs
@@ -40,5 +40,10 @@ export function createRoutes(views = {}) {
path: '/faq',
redirect: { name: 'about' },
},
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'not-found',
+ component: views.NotFoundView,
+ },
];
}
diff --git a/src/views/NotFoundView.vue b/src/views/NotFoundView.vue
new file mode 100644
index 0000000..b3ea708
--- /dev/null
+++ b/src/views/NotFoundView.vue
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+ Page not found
+
+
+ 404
+
+
...Oh, hey.
+
+ That page? Uh...
I don’t think we have it.
+
+
+
+ Back home
+
+
+
+
+ ...
+
+
+ NOT FOUND / 404
+
+
+
+
+
+
+
diff --git a/vite.config.mjs b/vite.config.mjs
index 9dc3765..57df523 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -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({
'\n',
].join('\n'),
});
+
+ this.emitFile({
+ type: 'asset',
+ fileName: '_redirects',
+ source: createRedirects(routes),
+ });
},
},
{