import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { copyFile, mkdir } from 'node:fs/promises' import * as path from 'node:path' import process from 'node:process' import { LOCALES, messages } from './app/locales/all.ts' import { PWA_INSTALL_CAPTURE_SCRIPT, PWA_INSTALL_DISMISSED_KEY, } from './app/utils/pwa-install.ts' import { RESTORE_SCRIPT } from './app/utils/theme.ts' import { PREFERENCE_RESTORE_SCRIPT } from './scripts/preference-restore.ts' import { BRAND_DESCRIPTION, THEME_COLOR_DARK, THEME_COLOR_LIGHT, } from './shared/brand.ts' import type { CharsData } from './shared/types.ts' import type { NuxtConfig } from 'nuxt/schema' /** * Every canonical row has a real static detail page. Aliases deliberately use * the static 404 fallback and redirect after hydration instead: emitting a * tiny file for each alias would consume thousands of the host's file quota. */ const charPath = (key: string) => `/char/${encodeURIComponent(key)}` const charsPath = path.resolve( import.meta.dirname, 'app/assets/data/chars.json', ) const fontStylesheetPaths = [ 'fonts-critical.css', 'fonts-ui.css', 'fonts-sans.css', 'fonts-serif.css', ].map((name) => path.resolve(import.meta.dirname, 'public/fonts', name)) function assertGeneratedAssets(): void { if ( !existsSync(charsPath) || fontStylesheetPaths.some((fontPath) => !existsSync(fontPath)) ) throw new Error( 'Missing generated character data or fonts; run pnpm build:data first.', ) } const publicCharsPath = path.resolve( import.meta.dirname, '.output/public/data/chars.json', ) const CHARS_SOURCE = existsSync(charsPath) ? readFileSync(charsPath) : null const CHARS_DATA = CHARS_SOURCE ? (JSON.parse(CHARS_SOURCE.toString()) as CharsData) : null const CHAR_KEYS = CHARS_DATA?.rows.map((row) => row.key) ?? [] const DATASET_STATS = { rows: CHARS_DATA?.stats.rows ?? 0, identical: CHARS_DATA?.stats.identical ?? 0, allDiffer: CHARS_DATA?.stats.allDiffer ?? 0, } const CHARS_REVISION = CHARS_SOURCE ? createHash('sha256').update(CHARS_SOURCE).digest('hex') : 'missing' const PRERENDERED_CHARS = CHAR_KEYS.map(charPath) const PRERENDER_ROUTES = ['/', '/about', ...PRERENDERED_CHARS] const SITEMAP_ROUTES = [ '/', '/about', ...CHAR_KEYS.map((key) => `/char/${key}`), ] const SITE_URL = process.env.NUXT_SITE_URL || 'https://hanji.sxzz.moe' const PWA_DEV = process.env.NUXT_PWA_DEV === '1' // Chrome and Edge 148+ choose these values from the browser's language // preferences. The default remains Simplified Chinese for older browsers. const PWA_LOCALIZED_MANIFEST = { name_localized: Object.fromEntries( LOCALES.map((locale) => [locale, messages[locale].meta.title]), ), short_name_localized: Object.fromEntries( LOCALES.map((locale) => [locale, messages[locale].meta.title]), ), description_localized: Object.fromEntries( LOCALES.map((locale) => [locale, messages[locale].meta.description]), ), } function resolveBuildSha(): string { const explicitSha = process.env.HANJI_BUILD_SHA?.trim() if (explicitSha && /^[\da-f]{40}$/i.test(explicitSha)) return explicitSha.toLowerCase() try { const gitSha = execFileSync( 'git', ['rev-parse', '--verify', 'HEAD^{commit}'], { cwd: import.meta.dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }, ).trim() return /^[\da-f]{40}$/i.test(gitSha) ? gitSha.toLowerCase() : '' } catch { // Source archives may not carry Git metadata. They still remain buildable. return '' } } const BUILD_INFO = { builtAt: new Date().toISOString(), sha: resolveBuildSha(), } // https://nuxt.com/docs/api/configuration/nuxt-config export default { hooks: { // Nuxt's prepare mode only writes types; it neither prerenders nor bundles // the app and can safely use an empty route list. Commands that build or // serve the app must still have the complete ignored generated assets. ready: (nuxt) => { if (!nuxt.options._prepare) assertGeneratedAssets() }, // Nuxt turns every manifest dynamic import into an eager . The 32 stroke-shard loaders are only useful after a // character's stroke panel enters the viewport, so keep the imports // themselves but let the browser fetch them on demand. 'build:manifest': (manifest) => { for (const chunk of Object.values(manifest) as Array<{ dynamicImports?: string[] }>) chunk.dynamicImports = [] }, // vite-plugin-pwa appends the manifest to its mutable additional-entry // array. Nuxt can regenerate the worker more than once in a static build, // so make that hook idempotent before Workbox receives the final list. 'pwa:beforeBuildServiceWorker': (options) => { // @vite-pwa/nuxt replaces a null fallback with `/` in development. // An empty string survives that normalization; turn it back into null // before Workbox validates the final configuration. if (PWA_DEV && options.workbox.navigateFallback === '') options.workbox.navigateFallback = null const seen = new Set() options.workbox.additionalManifestEntries = options.workbox.additionalManifestEntries?.filter((entry) => { const url = typeof entry === 'string' ? entry : entry.url if (seen.has(url)) return false seen.add(url) return true }) }, }, modules: [ 'goat-counter/nuxt', '@nuxtjs/sitemap', '@nuxtjs/robots', '@unocss/nuxt', '@vueuse/nuxt', '@vite-pwa/nuxt', ], goatCounter: { endpoint: 'https://stats.hanji.sxzz.moe/count', autoPageviews: false, params: { query: '' }, }, site: { url: SITE_URL, }, runtimeConfig: { public: { siteUrl: SITE_URL, }, }, appConfig: { buildInfo: BUILD_INFO, datasetStats: DATASET_STATS, }, pwa: { registerType: 'autoUpdate', manifestFilename: 'manifest.webmanifest', devOptions: { enabled: PWA_DEV, // The development worker exists only for installability testing. It // stays network-only below so it cannot cache Vite's HMR responses. suppressWarnings: true, }, // These assets are included by the final Workbox scan below. Turning off // Vite's earlier injection avoids duplicate entries when Nuxt regenerates // the service worker after prerendering. includeManifestIcons: false, client: { installPrompt: PWA_INSTALL_DISMISSED_KEY, periodicSyncForUpdates: 60 * 60, }, manifest: { ...PWA_LOCALIZED_MANIFEST, id: '/', name: messages['zh-CN'].meta.title, short_name: messages['zh-CN'].meta.title, description: BRAND_DESCRIPTION, lang: 'zh-CN', start_url: '/', scope: '/', display: 'standalone', orientation: 'any', background_color: '#fbfaf7', theme_color: '#fbfaf7', icons: [ { src: '/pwa/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any', }, { src: '/pwa/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any', }, { src: '/pwa/maskable-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable', }, ], screenshots: [ { src: '/pwa/screenshot-wide.png', sizes: '1280x720', type: 'image/png', form_factor: 'wide', label: '汉智桌面端字形对照界面', }, { src: '/pwa/screenshot-narrow.png', sizes: '390x844', type: 'image/png', form_factor: 'narrow', label: '汉智移动端字形对照界面', }, ], }, workbox: { cacheId: 'hanji', cleanupOutdatedCaches: true, disableDevLogs: true, globPatterns: [ '_nuxt/**/*', '200.html', 'favicon.svg', 'favicon-32x32.png', 'favicon.ico', 'logo.svg', 'logo-seal.svg', 'pwa/*.png', 'notices/**/*', ], // Nuxt normally rewrites every precached HTML filename to its route. // Keep the client-only fallback as a real file so Workbox can serve it // for an offline character URL that was never visited before. The // transform also removes duplicate assets collected from public/ and // the generated web manifest. manifestTransforms: [ (entries) => { const seen = new Set() return { manifest: entries.filter((entry) => { if (entry.url === '200.html') entry.url = '/200.html' if (seen.has(entry.url)) return false seen.add(entry.url) return true }), } }, ], maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, navigationPreload: !PWA_DEV, // The module defaults this to `/`, which would register a precached // route before the Network First handler below and bypass the network. navigateFallback: PWA_DEV ? '' : null, additionalManifestEntries: [ { url: '/data/chars.json', revision: CHARS_REVISION }, ], runtimeCaching: PWA_DEV ? [ { urlPattern: ({ request }) => request.mode === 'navigate', handler: 'NetworkOnly', }, ] : [ { urlPattern: ({ request }) => request.mode === 'navigate', handler: 'NetworkFirst', options: { cacheName: 'hanji-pages', networkTimeoutSeconds: 3, cacheableResponse: { statuses: [0, 200] }, expiration: { maxEntries: 64, maxAgeSeconds: 30 * 24 * 60 * 60, purgeOnQuotaError: true, }, precacheFallback: { fallbackURL: '/200.html' }, }, }, ], }, }, sitemap: { // These are already the exact canonical routes emitted below. Supplying // them directly avoids treating the dynamic page pattern or fallbacks as // indexable pages, while the module owns encoding and XML generation. excludeAppSources: true, urls: SITEMAP_ROUTES, discoverImages: false, discoverVideos: false, xsl: false, zeroRuntime: true, }, robots: { sitemap: '/sitemap.xml', }, vue: { optionsApi: false, }, // Keep build tooling inside Nuxt's generated Node project so the root // tsconfig reference checks scripts and config files without a parallel // hand-maintained tsconfig. typescript: { nodeTsConfig: { compilerOptions: { checkJs: true, types: ['node'], }, include: ['../scripts/*.ts', '../*.config.*'], }, }, experimental: { // The shared character dataset has its own cached URL rather than route // payloads, so extraction would emit one empty file for every character. // Keep hydration state inline and avoid spending one host file per page. payloadExtraction: false, // Install Nuxt's View Transition integration. app.viewTransition below // leaves it off until the global route middleware opts in per navigation. viewTransition: true, }, // Fully static. The language never enters the URL, so routes do not fan out // per locale -- these are all of them. nitro: { hooks: { // The app consumes the Vite-managed source, while external users need a // stable URL. Add that byte-for-byte copy only to the finished output so // it never bypasses Vite inside the source tree. 'prerender:done': async function () { await mkdir(path.dirname(publicCharsPath), { recursive: true }) await copyFile(charsPath, publicCharsPath) }, }, prerender: { // Crawling would walk every character linked from the first page; the // route list below is the deliberate one. crawlLinks: false, // Cloudflare serves /char/學 directly from char/學.html. Directory // indexes would first redirect the non-ASCII URL to /char/學/, and the // client-facing routes deliberately have no trailing slash. autoSubfolderIndex: false, routes: PRERENDER_ROUTES, }, }, css: [ '@unocss/reset/tailwind.css', '~/styles/vars.css', '~/styles/global.css', '~/styles/overprint.css', ], app: { // Only home <-> character detail opts in; every other route change is // deliberately immediate. viewTransition: false, head: { htmlAttrs: { lang: 'zh-CN', 'data-style': 'sans' }, // app.vue does not render into Nitro's client-only fallback documents. // Keep the bootstraps static so 200.html and 404.html also restore the // theme and suppress a mismatching preference frame before first paint. script: [ { innerHTML: RESTORE_SCRIPT, tagPosition: 'head' }, { innerHTML: PREFERENCE_RESTORE_SCRIPT, tagPosition: 'head' }, { innerHTML: PWA_INSTALL_CAPTURE_SCRIPT, tagPosition: 'head' }, ], link: [ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png', }, { rel: 'apple-touch-icon', sizes: '180x180', href: '/pwa/apple-touch-icon-180.png', }, { rel: 'manifest', href: '/manifest.webmanifest' }, ], meta: [ { name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover', }, { name: 'theme-color', media: '(prefers-color-scheme: light)', content: THEME_COLOR_LIGHT, }, { name: 'theme-color', media: '(prefers-color-scheme: dark)', content: THEME_COLOR_DARK, }, { name: 'mobile-web-app-capable', content: 'yes' }, { name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'application-name', content: '汉智' }, { name: 'apple-mobile-web-app-title', content: '汉智' }, { name: 'description', content: BRAND_DESCRIPTION, }, ], }, }, devtools: { enabled: false }, compatibilityDate: 'latest', } satisfies NuxtConfig