feat: i18n PWA webmanifests (#805)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
18056038c7
commit
85ac005570
|
@ -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`,
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
85
modules/pwa/i18n.ts
Normal 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())
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -113,7 +113,7 @@ export default defineNuxtConfig({
|
||||||
],
|
],
|
||||||
prerender: {
|
prerender: {
|
||||||
crawlLinks: false,
|
crawlLinks: false,
|
||||||
routes: ['/', '/200.html'],
|
routes: ['/'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
|
|
Loading…
Reference in a new issue