feat: i18n PWA webmanifests (#805)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
Joaquín Sánchez 2023-01-06 13:48:43 +01:00 committed by GitHub
parent 18056038c7
commit 85ac005570
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 190 additions and 73 deletions

View file

@ -1,5 +1,3 @@
import { pwaInfo } from 'virtual:pwa-info'
import type { Link } from '@unhead/schema'
import type { Directions } from 'vue-i18n-routing' import type { Directions } from 'vue-i18n-routing'
import { buildInfo } from 'virtual:build-info' import { buildInfo } from 'virtual:build-info'
import type { LocaleObject } from '#i18n' import type { LocaleObject } from '#i18n'
@ -7,28 +5,6 @@ import type { LocaleObject } from '#i18n'
export function setupPageHeader() { export function setupPageHeader() {
const { locale, locales, t } = useI18n() 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) => { const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
acc[l.code!] = l.dir ?? 'auto' acc[l.code!] = l.dir ?? 'auto'
return acc return acc
@ -46,6 +22,12 @@ export function setupPageHeader() {
titleTemplate += ` (${buildInfo.env})` titleTemplate += ` (${buildInfo.env})`
return titleTemplate return titleTemplate
}, },
link, link: process.client && useRuntimeConfig().public.pwaEnabled
? () => [{
key: 'webmanifest',
rel: 'manifest',
href: `/manifest-${locale.value}.webmanifest`,
}]
: [],
}) })
} }

View file

@ -1,7 +1,5 @@
import { isCI, isDevelopment } from 'std-env' import { isCI, isDevelopment } from 'std-env'
import type { VitePWANuxtOptions } from '../modules/pwa/types' import type { VitePWANuxtOptions } from '../modules/pwa/types'
import { APP_NAME } from '../constants'
import { getEnv } from './env'
export const pwa: VitePWANuxtOptions = { export const pwa: VitePWANuxtOptions = {
mode: isCI ? 'production' : 'development', mode: isCI ? 'production' : 'development',
@ -13,40 +11,9 @@ export const pwa: VitePWANuxtOptions = {
strategies: 'injectManifest', strategies: 'injectManifest',
injectRegister: false, injectRegister: false,
includeManifestIcons: false, includeManifestIcons: false,
manifest: async () => { manifest: false,
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',
},
*/
],
}
},
injectManifest: { 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/**'], globIgnores: ['emojis/**'],
}, },
devOptions: { devOptions: {

View file

@ -187,7 +187,29 @@
"dismiss": "Dismiss", "dismiss": "Dismiss",
"title": "New Elk update available!", "title": "New Elk update available!",
"update": "Update", "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": {
"search_desc": "Search for people & hashtags" "search_desc": "Search for people & hashtags"

85
modules/pwa/i18n.ts Normal file
View file

@ -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<string, Partial<ManifestOptions>>
export const pwaLocales = i18n.locales as LocaleObject[]
type WebManifestEntry = Pick<ManifestOptions, 'name' | 'short_name' | 'description'>
type RequiredWebManifestEntry = Required<WebManifestEntry & Pick<ManifestOptions, 'dir' | 'lang'>>
export const createI18n = async (): Promise<LocalizedWebManifest> => {
const { env } = await getEnv()
const envName = `${env === 'release' ? '' : `(${env})`}`
const { pwa } = await readI18nFile('en-US.json')
const defaultManifest: Required<WebManifestEntry> = 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 <RequiredWebManifestEntry>{
...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())
}

View file

@ -1,9 +1,10 @@
import { defineNuxtModule } from '@nuxt/kit' 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 { VitePWA } from 'vite-plugin-pwa'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import type { VitePWANuxtOptions } from './types' import type { VitePWANuxtOptions } from './types'
import { configurePWAOptions } from './config' import { configurePWAOptions } from './config'
import { type LocalizedWebManifest, createI18n, pwaLocales } from './i18n'
export * from './types' export * from './types'
export default defineNuxtModule<VitePWANuxtOptions>({ export default defineNuxtModule<VitePWANuxtOptions>({
@ -20,6 +21,7 @@ export default defineNuxtModule<VitePWANuxtOptions>({
const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => { const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => {
return vitePwaClientPlugin?.api return vitePwaClientPlugin?.api
} }
let webmanifests: LocalizedWebManifest | undefined
// TODO: combine with configurePWAOptions? // TODO: combine with configurePWAOptions?
nuxt.hook('nitro:init', (nitro) => { nuxt.hook('nitro:init', (nitro) => {
@ -37,12 +39,55 @@ export default defineNuxtModule<VitePWANuxtOptions>({
const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa')
if (plugin) if (plugin)
throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!')
const resolvedOptions: Partial<VitePWAOptions> = {
...options, webmanifests = await createI18n()
manifest: options.manifest ? await options.manifest() : undefined, 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) viteInlineConfig.plugins.push({
const plugins = VitePWA(resolvedOptions) 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) viteInlineConfig.plugins.push(plugins)
if (isClient) if (isClient)
vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin
@ -61,8 +106,17 @@ export default defineNuxtModule<VitePWANuxtOptions>({
return return
viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle }) 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 }) viteServer.middlewares.stack.push({ route: devSw, handle: emptyHandle })
}) })
if (!options.strategies || options.strategies === 'generateSW') { if (!options.strategies || options.strategies === 'generateSW') {
nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => {
if (isServer) if (isServer)
@ -76,6 +130,16 @@ export default defineNuxtModule<VitePWANuxtOptions>({
} }
} }
else { 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) => { nuxt.hook('nitro:init', (nitro) => {
nitro.hooks.hook('rollup:before', async () => { nitro.hooks.hook('rollup:before', async () => {
await resolveVitePluginPWAAPI()?.generateSW() await resolveVitePluginPWAAPI()?.generateSW()

View file

@ -1,9 +1,6 @@
import type { ManifestOptions, VitePWAOptions } from 'vite-plugin-pwa' import type { VitePWAOptions } from 'vite-plugin-pwa'
import type { Overwrite } from '../../types/utils'
export type VitePWANuxtOptions = Overwrite<Partial<VitePWAOptions>, { export interface VitePWANuxtOptions extends Partial<VitePWAOptions> {}
manifest?: () => Promise<Partial<ManifestOptions>>
}>
declare module '@nuxt/schema' { declare module '@nuxt/schema' {
interface NuxtConfig { interface NuxtConfig {

View file

@ -113,7 +113,7 @@ export default defineNuxtConfig({
], ],
prerender: { prerender: {
crawlLinks: false, crawlLinks: false,
routes: ['/', '/200.html'], routes: ['/'],
}, },
}, },
app: { app: {