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 @@ + + + + + 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), + }); }, }, {