From 85ac005570aca45184d7c91736b3f6914b288fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20S=C3=A1nchez?= Date: Fri, 6 Jan 2023 13:48:43 +0100 Subject: [PATCH] feat: i18n PWA webmanifests (#805) Co-authored-by: Daniel Roe --- composables/setups.ts | 32 ++++------------ config/pwa.ts | 37 +------------------ locales/en-US.json | 24 +++++++++++- modules/pwa/i18n.ts | 85 +++++++++++++++++++++++++++++++++++++++++++ modules/pwa/index.ts | 76 +++++++++++++++++++++++++++++++++++--- modules/pwa/types.ts | 7 +--- nuxt.config.ts | 2 +- 7 files changed, 190 insertions(+), 73 deletions(-) create mode 100644 modules/pwa/i18n.ts diff --git a/composables/setups.ts b/composables/setups.ts index 5a9947f2..4e1c09c2 100644 --- a/composables/setups.ts +++ b/composables/setups.ts @@ -1,5 +1,3 @@ -import { pwaInfo } from 'virtual:pwa-info' -import type { Link } from '@unhead/schema' import type { Directions } from 'vue-i18n-routing' import { buildInfo } from 'virtual:build-info' import type { LocaleObject } from '#i18n' @@ -7,28 +5,6 @@ import type { LocaleObject } from '#i18n' export function setupPageHeader() { const { locale, locales, t } = useI18n() - const link: Link[] = [] - - if (pwaInfo && pwaInfo.webManifest) { - const { webManifest } = pwaInfo - if (webManifest) { - const { href, useCredentials } = webManifest - if (useCredentials) { - link.push({ - rel: 'manifest', - href, - crossorigin: 'use-credentials', - }) - } - else { - link.push({ - rel: 'manifest', - href, - }) - } - } - } - const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => { acc[l.code!] = l.dir ?? 'auto' return acc @@ -46,6 +22,12 @@ export function setupPageHeader() { titleTemplate += ` (${buildInfo.env})` return titleTemplate }, - link, + link: process.client && useRuntimeConfig().public.pwaEnabled + ? () => [{ + key: 'webmanifest', + rel: 'manifest', + href: `/manifest-${locale.value}.webmanifest`, + }] + : [], }) } diff --git a/config/pwa.ts b/config/pwa.ts index 4711bc40..46c0a066 100644 --- a/config/pwa.ts +++ b/config/pwa.ts @@ -1,7 +1,5 @@ import { isCI, isDevelopment } from 'std-env' import type { VitePWANuxtOptions } from '../modules/pwa/types' -import { APP_NAME } from '../constants' -import { getEnv } from './env' export const pwa: VitePWANuxtOptions = { mode: isCI ? 'production' : 'development', @@ -13,40 +11,9 @@ export const pwa: VitePWANuxtOptions = { strategies: 'injectManifest', injectRegister: false, includeManifestIcons: false, - manifest: async () => { - const { env } = await getEnv() - const envName = `${env === 'release' ? '' : ` (${env})`}` - return { - scope: '/', - id: '/', - name: `${APP_NAME}${envName}`, - short_name: `${APP_NAME}${envName}`, - description: `A nimble Mastodon Web Client${envName}`, - theme_color: '#ffffff', - icons: [ - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png', - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png', - }, - /* - { - src: 'logo.svg', - sizes: '250x250', - type: 'image/png', - purpose: 'any maskable', - }, - */ - ], - } - }, + manifest: false, injectManifest: { - globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'], + globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm,webmanifest}'], globIgnores: ['emojis/**'], }, devOptions: { diff --git a/locales/en-US.json b/locales/en-US.json index e1a57bf9..7fcf4839 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -187,7 +187,29 @@ "dismiss": "Dismiss", "title": "New Elk update available!", "update": "Update", - "update_available_short": "Update Elk" + "update_available_short": "Update Elk", + "webmanifest": { + "dev": { + "description": "A nimble Mastodon web client (dev)", + "name": "Elk (dev)", + "short_name": "Elk (dev)" + }, + "main": { + "description": "A nimble Mastodon web client (main)", + "name": "Elk (main)", + "short_name": "Elk (main)" + }, + "preview": { + "description": "A nimble Mastodon web client (preview)", + "name": "Elk (preview)", + "short_name": "Elk (preview)" + }, + "release": { + "description": "A nimble Mastodon web client", + "name": "Elk", + "short_name": "Elk" + } + } }, "search": { "search_desc": "Search for people & hashtags" diff --git a/modules/pwa/i18n.ts b/modules/pwa/i18n.ts new file mode 100644 index 00000000..5774f40f --- /dev/null +++ b/modules/pwa/i18n.ts @@ -0,0 +1,85 @@ +import { readFile } from 'fs-extra' +import { resolve } from 'pathe' +import type { ManifestOptions } from 'vite-plugin-pwa' +import { getEnv } from '../../config/env' +import { i18n } from '../../config/i18n' +import type { LocaleObject } from '#i18n' + +export type LocalizedWebManifest = Record> + +export const pwaLocales = i18n.locales as LocaleObject[] + +type WebManifestEntry = Pick +type RequiredWebManifestEntry = Required> + +export const createI18n = async (): Promise => { + const { env } = await getEnv() + const envName = `${env === 'release' ? '' : `(${env})`}` + const { pwa } = await readI18nFile('en-US.json') + + const defaultManifest: Required = pwa.webmanifest[env] + + const locales: RequiredWebManifestEntry[] = await Promise.all( + pwaLocales + .filter(l => l.code !== 'en-US') + .map(async ({ code, dir = 'ltr', file }) => { + // read locale file + const { pwa, app_name, app_desc_short } = await readI18nFile(file!) + const entry: WebManifestEntry = pwa?.webmanifest?.[env] ?? {} + if (!entry.name && app_name) + entry.name = dir === 'rtl' ? `${envName} ${app_name}` : `${app_name} ${envName}` + + if (!entry.short_name && app_name) + entry.short_name = dir === 'rtl' ? `${envName} ${app_name}` : `${app_name} ${envName}` + + if (!entry.description && app_desc_short) + entry.description = app_desc_short + + return { + ...defaultManifest, + ...entry, + lang: code, + dir, + } + }), + ) + locales.push({ + ...defaultManifest, + lang: 'en-US', + dir: 'ltr', + }) + return locales.reduce((acc, { lang, dir, name, short_name, description }) => { + acc[lang] = { + scope: '/', + id: '/', + start_url: '/', + display: 'standalone', + lang, + name, + short_name, + description, + dir, + theme_color: '#ffffff', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + } + + return acc + }, {} as LocalizedWebManifest) +} + +async function readI18nFile(file: string) { + return JSON.parse(Buffer.from( + await readFile(resolve(`./locales/${file}`), 'utf-8'), + ).toString()) +} diff --git a/modules/pwa/index.ts b/modules/pwa/index.ts index fbf369fc..1cd02d4a 100644 --- a/modules/pwa/index.ts +++ b/modules/pwa/index.ts @@ -1,9 +1,10 @@ import { defineNuxtModule } from '@nuxt/kit' -import type { VitePWAOptions, VitePluginPWAAPI } from 'vite-plugin-pwa' +import type { VitePluginPWAAPI } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa' import type { Plugin } from 'vite' import type { VitePWANuxtOptions } from './types' import { configurePWAOptions } from './config' +import { type LocalizedWebManifest, createI18n, pwaLocales } from './i18n' export * from './types' export default defineNuxtModule({ @@ -20,6 +21,7 @@ export default defineNuxtModule({ const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => { return vitePwaClientPlugin?.api } + let webmanifests: LocalizedWebManifest | undefined // TODO: combine with configurePWAOptions? nuxt.hook('nitro:init', (nitro) => { @@ -37,12 +39,55 @@ export default defineNuxtModule({ const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') if (plugin) throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') - const resolvedOptions: Partial = { - ...options, - manifest: options.manifest ? await options.manifest() : undefined, + + webmanifests = await createI18n() + const generateManifest = (locale: string) => { + const manifest = webmanifests![locale] + if (!manifest) + throw new Error(`No webmanifest found for locale ${locale}`) + return JSON.stringify(manifest) } - configurePWAOptions(resolvedOptions, nuxt) - const plugins = VitePWA(resolvedOptions) + viteInlineConfig.plugins.push({ + name: 'elk:pwa:locales:build', + apply: 'build', + generateBundle(_, bundle) { + if (options.disable || !bundle) + return + + Object.keys(webmanifests!).map(l => [l, `manifest-${l}.webmanifest`]).forEach(([l, fileName]) => { + bundle[fileName] = { + isAsset: true, + type: 'asset', + name: undefined, + source: generateManifest(l), + fileName, + } + }) + }, + }) + viteInlineConfig.plugins.push({ + name: 'elk:pwa:locales:dev', + apply: 'serve', + configureServer(server) { + const localeMatcher = new RegExp(`^${nuxt.options.app.baseURL}manifest-(.*).webmanifest$`) + server.middlewares.use((req, res, next) => { + const match = req.url?.match(localeMatcher) + const entry = match && webmanifests![match[1]] + if (entry) { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/manifest+json') + res.write(JSON.stringify(entry), 'utf-8') + res.end() + } + else { + next() + } + }) + }, + }) + + configurePWAOptions(options, nuxt) + const plugins = VitePWA(options) viteInlineConfig.plugins.push(plugins) if (isClient) vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin @@ -61,8 +106,17 @@ export default defineNuxtModule({ return viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle }) + if (webmanifests) { + Object.keys(webmanifests).forEach((locale) => { + viteServer.middlewares.stack.push({ + route: `${nuxt.options.app.baseURL}manifest-${locale}.webmanifest`, + handle: emptyHandle, + }) + }) + } viteServer.middlewares.stack.push({ route: devSw, handle: emptyHandle }) }) + if (!options.strategies || options.strategies === 'generateSW') { nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { if (isServer) @@ -76,6 +130,16 @@ export default defineNuxtModule({ } } else { + nuxt.hook('nitro:config', async (nitroConfig) => { + nitroConfig.routeRules = nitroConfig.routeRules || {} + for (const locale of pwaLocales) { + nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = { + headers: { + 'Content-Type': 'application/manifest+json', + }, + } + } + }) nuxt.hook('nitro:init', (nitro) => { nitro.hooks.hook('rollup:before', async () => { await resolveVitePluginPWAAPI()?.generateSW() diff --git a/modules/pwa/types.ts b/modules/pwa/types.ts index 7d3af0d5..90446c52 100644 --- a/modules/pwa/types.ts +++ b/modules/pwa/types.ts @@ -1,9 +1,6 @@ -import type { ManifestOptions, VitePWAOptions } from 'vite-plugin-pwa' -import type { Overwrite } from '../../types/utils' +import type { VitePWAOptions } from 'vite-plugin-pwa' -export type VitePWANuxtOptions = Overwrite, { - manifest?: () => Promise> -}> +export interface VitePWANuxtOptions extends Partial {} declare module '@nuxt/schema' { interface NuxtConfig { diff --git a/nuxt.config.ts b/nuxt.config.ts index c46c7bca..4b423a6e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -113,7 +113,7 @@ export default defineNuxtConfig({ ], prerender: { crawlLinks: false, - routes: ['/', '/200.html'], + routes: ['/'], }, }, app: {