refactor: 💡 separation of a lot of concerns

This commit is contained in:
Christian Kaisermann 2019-11-20 16:31:55 -03:00
parent 3dce407f4f
commit bf5ad6e387
22 changed files with 443 additions and 329 deletions

View File

@ -1,6 +1,6 @@
{
"semi": false,
"printWidth": 100,
"printWidth": 80,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,

View File

@ -40,7 +40,6 @@ export default {
}),
commonjs(),
json(),
legacy &&
babel({
extensions: ['.js', '.mjs', '.html', '.svelte'],
@ -73,7 +72,6 @@ export default {
onwarn,
},
server: {
input: config.server.input(),
output: config.server.output(),
@ -94,9 +92,8 @@ export default {
],
external: Object.keys(pkg.dependencies).concat(
require('module').builtinModules ||
Object.keys(process.binding('natives')),
Object.keys(process.binding('natives'))
),
onwarn,
},

View File

@ -12,7 +12,7 @@ polka() // You can also use Express
.use(
compression({ threshold: 0 }),
sirv('static', { dev }),
sapper.middleware(),
sapper.middleware()
)
.listen(PORT, err => {
if (err) console.log('error', err)

View File

@ -1,82 +1,85 @@
import { timestamp, files, shell, routes } from '@sapper/service-worker';
import { timestamp, files, shell, routes } from '@sapper/service-worker'
const ASSETS = `cache${timestamp}`;
const ASSETS = `cache${timestamp}`
// `shell` is an array of all the files generated by the bundler,
// `files` is an array of everything in the `static` directory
const to_cache = shell.concat(files);
const cached = new Set(to_cache);
const to_cache = shell.concat(files)
const cached = new Set(to_cache)
self.addEventListener('install', event => {
event.waitUntil(
caches
.open(ASSETS)
.then(cache => cache.addAll(to_cache))
.then(() => {
self.skipWaiting();
})
);
});
event.waitUntil(
caches
.open(ASSETS)
.then(cache => cache.addAll(to_cache))
.then(() => {
self.skipWaiting()
})
)
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(async keys => {
// delete old caches
for (const key of keys) {
if (key !== ASSETS) await caches.delete(key);
}
event.waitUntil(
caches.keys().then(async keys => {
// delete old caches
for (const key of keys) {
if (key !== ASSETS) await caches.delete(key)
}
self.clients.claim();
})
);
});
self.clients.claim()
})
)
})
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET' || event.request.headers.has('range')) return;
if (event.request.method !== 'GET' || event.request.headers.has('range'))
return
const url = new URL(event.request.url);
const url = new URL(event.request.url)
// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) return;
// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) return
// ignore dev server requests
if (url.hostname === self.location.hostname && url.port !== self.location.port) return;
// ignore dev server requests
if (
url.hostname === self.location.hostname &&
url.port !== self.location.port
)
return
// always serve static files and bundler-generated assets from cache
if (url.host === self.location.host && cached.has(url.pathname)) {
event.respondWith(caches.match(event.request));
return;
}
// always serve static files and bundler-generated assets from cache
if (url.host === self.location.host && cached.has(url.pathname)) {
event.respondWith(caches.match(event.request))
return
}
// for pages, you might want to serve a shell `service-worker-index.html` file,
// which Sapper has generated for you. It's not right for every
// app, but if it's right for yours then uncomment this section
/*
// for pages, you might want to serve a shell `service-worker-index.html` file,
// which Sapper has generated for you. It's not right for every
// app, but if it's right for yours then uncomment this section
/*
if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
event.respondWith(caches.match('/service-worker-index.html'));
return;
}
*/
if (event.request.cache === 'only-if-cached') return;
if (event.request.cache === 'only-if-cached') return
// for everything else, try the network first, falling back to
// cache if the user is offline. (If the pages never change, you
// might prefer a cache-first approach to a network-first one.)
event.respondWith(
caches
.open(`offline${timestamp}`)
.then(async cache => {
try {
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
} catch(err) {
const response = await cache.match(event.request);
if (response) return response;
// for everything else, try the network first, falling back to
// cache if the user is offline. (If the pages never change, you
// might prefer a cache-first approach to a network-first one.)
event.respondWith(
caches.open(`offline${timestamp}`).then(async cache => {
try {
const response = await fetch(event.request)
cache.put(event.request, response.clone())
return response
} catch (err) {
const response = await cache.match(event.request)
if (response) return response
throw err;
}
})
);
});
throw err
}
})
)
})

View File

@ -691,9 +691,9 @@
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/node@*":
version "12.12.9"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.9.tgz#0b5ae05516b757cbff2e82c04500190aef986c7b"
integrity sha512-kV3w4KeLsRBW+O2rKhktBwENNJuqAUQHS3kf4ia2wIaF/MN6U7ANgTsx7tGremcA0Pk3Yh0Hl0iKiLPuBdIgmw==
version "12.12.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.11.tgz#bec2961975888d964196bf0016a2f984d793d3ce"
integrity sha512-O+x6uIpa6oMNTkPuHDa9MhMMehlxLAd5QcOvKRjAFsBVpeFWTOPnXbDvILvFgFFZfQ1xh1EZi1FbXxUix+zpsQ==
"@types/resolve@0.0.8":
version "0.0.8"
@ -775,9 +775,9 @@ camel-case@^3.0.0:
upper-case "^1.1.1"
caniuse-lite@^1.0.30001010:
version "1.0.30001010"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001010.tgz#397a14034d384260453cc81994f494626d34b938"
integrity sha512-RA5GH9YjFNea4ZQszdWgh2SC+dpLiRAg4VDQS2b5JRI45OxmbGrYocYHTa9x0bKMQUE7uvHkNPNffUr+pCxSGw==
version "1.0.30001011"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001011.tgz#0d6c4549c78c4a800bb043a83ca0cbe0aee6c6e1"
integrity sha512-h+Eqyn/YA6o6ZTqpS86PyRmNWOs1r54EBDcd2NTwwfsXQ8re1B38SnB+p2RKF8OUsyEIjeDU8XGec1RGO/wYCg==
chalk@^2.0.0, chalk@^2.4.1:
version "2.4.2"
@ -890,9 +890,9 @@ define-properties@^1.1.2, define-properties@^1.1.3:
object-keys "^1.0.12"
electron-to-chromium@^1.3.306:
version "1.3.306"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.306.tgz#e8265301d053d5f74e36cb876486830261fbe946"
integrity sha512-frDqXvrIROoYvikSKTIKbHbzO6M3/qC6kCIt/1FOa9kALe++c4VAJnwjSFvf1tYLEUsP2n9XZ4XSCyqc3l7A/A==
version "1.3.307"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.307.tgz#e9b98901371d20164af7c0ca5bc820bd4305ccd3"
integrity sha512-01rTsAqHwf3D2X6NtlUvzB2hxDj67kiTVIO5GWdFb2unA0QvFvrjyrtc993ByRLF+surlr+9AvJdD0UYs5HzwA==
error-ex@^1.3.1:
version "1.3.2"
@ -1492,9 +1492,9 @@ rollup-pluginutils@^2.3.3, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0,
estree-walker "^0.6.1"
rollup@^1.12.0:
version "1.27.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.27.2.tgz#caf54a93df228bf7864f13dddcdb363d3e958509"
integrity sha512-sD3iyd0zlvgK1S3MmICi6F/Y+R/QWY5XxzsTGN4pAd+nCasDUizmAhgq2hdh1t2eLux974NHU2TW41fhuGPv+Q==
version "1.27.3"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.27.3.tgz#0e427d9bee9e30e6017e89b942d88276ce26f9e2"
integrity sha512-79AEh4m5NPCz97GTuIoXpSFIMPyk2AiqVQp040baSRPXk/I4YMGt5/CR9GX5oEYEkxwBZoWLheaS1/w/FidfJw==
dependencies:
"@types/estree" "*"
"@types/node" "*"

View File

@ -78,7 +78,9 @@
"commander": "^4.0.1",
"deepmerge": "^4.2.2",
"estree-walker": "^0.9.0",
"fast-memoize": "^2.5.1",
"intl-messageformat": "^7.5.2",
"nano-memoize": "^1.1.7",
"object-resolve-path": "^1.1.1",
"tiny-glob": "^0.2.6"
}

View File

@ -74,7 +74,7 @@ function getLibImportDeclarations(ast: Ast) {
return (ast.instance
? ast.instance.content.body.filter(
node =>
node.type === 'ImportDeclaration' && node.source.value === LIB_NAME,
node.type === 'ImportDeclaration' && node.source.value === LIB_NAME
)
: []) as ImportDeclaration[]
}
@ -82,13 +82,13 @@ function getLibImportDeclarations(ast: Ast) {
function getDefineMessagesSpecifier(decl: ImportDeclaration) {
return decl.specifiers.find(
spec =>
'imported' in spec && spec.imported.name === DEFINE_MESSAGES_METHOD_NAME,
'imported' in spec && spec.imported.name === DEFINE_MESSAGES_METHOD_NAME
) as ImportSpecifier
}
function getFormatSpecifiers(decl: ImportDeclaration) {
return decl.specifiers.filter(
spec => 'imported' in spec && FORMAT_METHOD_NAMES.has(spec.imported.name),
spec => 'imported' in spec && FORMAT_METHOD_NAMES.has(spec.imported.name)
) as ImportSpecifier[]
}
@ -104,7 +104,7 @@ function getObjFromExpression(exprNode: Node | ObjectExpression) {
}
return acc
},
{ node: exprNode, meta: {} },
{ node: exprNode, meta: {} }
)
}
@ -115,8 +115,8 @@ export function collectFormatCalls(ast: Ast) {
const imports = new Set(
importDecls.flatMap(decl =>
getFormatSpecifiers(decl).map(n => n.local.name),
),
getFormatSpecifiers(decl).map(n => n.local.name)
)
)
if (imports.size === 0) return []
@ -137,7 +137,7 @@ export function collectFormatCalls(ast: Ast) {
export function collectMessageDefinitions(ast: Ast) {
const definitions: ObjectExpression[] = []
const defineImportDecl = getLibImportDeclarations(ast).find(
getDefineMessagesSpecifier,
getDefineMessagesSpecifier
)
if (defineImportDecl == null) return []
@ -157,7 +157,7 @@ export function collectMessageDefinitions(ast: Ast) {
})
return definitions.flatMap(definitionDict =>
definitionDict.properties.map(propNode => propNode.value),
definitionDict.properties.map(propNode => propNode.value)
)
}
@ -193,7 +193,7 @@ export function collectMessages(markup: string): Message[] {
export function extractMessages(
markup: string,
{ accumulator = {}, shallow = false, overwrite = false } = {} as any,
{ accumulator = {}, shallow = false, overwrite = false } = {} as any
) {
collectMessages(markup).forEach(message => {
let defaultValue = message.meta.default

View File

@ -20,21 +20,21 @@ program
.option(
'-s, --shallow',
'extract to a shallow dictionary (ids with dots interpreted as strings, not paths)',
false,
false
)
.option(
'--overwrite',
'overwrite the content of the output file instead of just appending new properties',
false,
false
)
.option(
'-c, --config <dir>',
'path to the "svelte.config.js" file',
process.cwd(),
process.cwd()
)
.action(async (globStr, output, { shallow, overwrite, config }) => {
const filesToExtract = (await glob(globStr)).filter(file =>
file.match(/\.html|svelte$/i),
file.match(/\.html|svelte$/i)
)
const svelteConfig = await import(
resolve(config, 'svelte.config.js')

View File

@ -1,7 +1,8 @@
import IntlMessageFormat, { Formats } from 'intl-messageformat'
import memoize from 'micro-memoize'
import memoize from 'fast-memoize'
import { MemoizedIntlFormatter } from './types'
import { MemoizedIntlFormatter } from '../types'
import { getCurrentLocale } from '../stores/locale'
export const customFormats: any = {
number: {
@ -22,7 +23,7 @@ export function addCustomFormats(formats: Partial<Formats>) {
const getIntlFormatterOptions = (
type: 'time' | 'number' | 'date',
name: string,
name: string
): any => {
if (type in customFormats && name in customFormats[type]) {
return customFormats[type][name]
@ -41,8 +42,8 @@ const getIntlFormatterOptions = (
export const getNumberFormatter: MemoizedIntlFormatter<
Intl.NumberFormat,
Intl.NumberFormatOptions
> = memoize((locale, options = {}) => {
if (options.locale) locale = options.locale
> = memoize((options = {}) => {
const locale = options.locale || getCurrentLocale()
if (options.format) {
const format = getIntlFormatterOptions('number', options.format)
if (format) options = format
@ -53,28 +54,28 @@ export const getNumberFormatter: MemoizedIntlFormatter<
export const getDateFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize((locale, options = { format: 'short' }) => {
if (options.locale) locale = options.locale
if (options.format) {
const format = getIntlFormatterOptions('date', options.format)
if (format) options = format
}
> = memoize((options = { format: 'short' }) => {
const locale = options.locale || getCurrentLocale()
const format = getIntlFormatterOptions('date', options.format)
if (format) options = format
return new Intl.DateTimeFormat(locale, options)
})
export const getTimeFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize((locale, options = { format: 'short' }) => {
if (options.locale) locale = options.locale
if (options.format) {
const format = getIntlFormatterOptions('time', options.format)
if (format) options = format
}
> = memoize((options = { format: 'short' }) => {
const locale = options.locale || getCurrentLocale()
const format = getIntlFormatterOptions('time', options.format)
if (format) options = format
return new Intl.DateTimeFormat(locale, options)
})
export const getMessageFormatter = memoize(
(message: string, locale: string) =>
new IntlMessageFormat(message, locale, customFormats),
new IntlMessageFormat(message, locale, customFormats)
)

View File

@ -0,0 +1,67 @@
import merge from 'deepmerge'
import { LocaleLoader } from '../types'
import { hasLocaleDictionary, $dictionary } from '../stores/dictionary'
import { getCurrentLocale } from '../stores/locale'
import { $loading } from '../stores/loading'
import { removeFromLookupCache } from './lookup'
const loaderQueue: Record<string, Set<LocaleLoader>> = {}
function getLocaleQueue(locale: string) {
return loaderQueue[locale]
}
function createLocaleQueue(locale: string) {
loaderQueue[locale] = new Set()
}
function removeLocaleFromQueue(locale: string) {
delete loaderQueue[locale]
}
export function addLoaderToQueue(locale: string, loader: LocaleLoader) {
loaderQueue[locale].add(loader)
}
export async function flushQueue(locale: string = getCurrentLocale()) {
if (!getLocaleQueue(locale)) return
const queue = [...getLocaleQueue(locale)]
if (queue.length === 0) return
removeLocaleFromQueue(locale)
$loading.set(true)
// todo what happens if some loader fails?
return Promise.all(queue.map(loader => loader()))
.then(partials => {
partials = partials.map(partial => partial.default || partial)
removeFromLookupCache(locale)
$dictionary.update(d => {
d[locale] = merge.all<any>([d[locale] || {}].concat(partials))
return d
})
})
.then(() => $loading.set(false))
}
export function registerLocaleLoader(locale: string, loader: LocaleLoader) {
if (!getLocaleQueue(locale)) createLocaleQueue(locale)
const queue = getLocaleQueue(locale)
if (queue.has(loader)) return
if (!hasLocaleDictionary(locale)) {
$dictionary.update(d => {
d[locale] = {}
return d
})
}
queue.add(loader)
}

View File

@ -0,0 +1,42 @@
// todo invalidate only keys with null values
import resolvePath from 'object-resolve-path'
import { hasLocaleDictionary } from '../stores/dictionary'
import { getGenericLocaleFrom } from './utils'
const lookupCache: Record<string, Record<string, string>> = {}
const addToCache = (path: string, locale: string, message: string) => {
if (!(locale in lookupCache)) lookupCache[locale] = {}
if (!(path in lookupCache[locale])) lookupCache[locale][path] = message
return message
}
export const removeFromLookupCache = (locale: string) => {
delete lookupCache[locale]
}
export const lookupMessage = (
dictionary: any,
path: string,
locale: string
): string => {
if (locale == null) return null
if (locale in lookupCache && path in lookupCache[locale]) {
return lookupCache[locale][path]
}
if (hasLocaleDictionary(locale)) {
if (path in dictionary[locale]) {
return addToCache(path, locale, dictionary[locale][path])
}
const message = resolvePath(dictionary[locale], path)
if (message) return addToCache(path, locale, message)
}
return addToCache(
path,
locale,
lookupMessage(dictionary, path, getGenericLocaleFrom(locale))
)
}

View File

@ -1,7 +1,18 @@
export const capital = (str: string) => str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase())
export const title = (str: string) => str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase())
export const upper = (str: string) => str.toLocaleUpperCase()
export const lower = (str: string) => str.toLocaleLowerCase()
export function capital(str: string) {
return str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase())
}
export function title(str: string) {
return str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase())
}
export function upper(str: string) {
return str.toLocaleUpperCase()
}
export function lower(str: string) {
return str.toLocaleLowerCase()
}
export function getGenericLocaleFrom(locale: string) {
const index = locale.lastIndexOf('-')
@ -12,6 +23,7 @@ export function getLocalesFrom(locale: string) {
return locale.split('-').map((_, i, arr) => arr.slice(0, i + 1).join('-'))
}
// todo add a urlPattern method/regexp
export const getClientLocale = ({
navigator,
hash,

View File

@ -1,170 +1,23 @@
import { writable, derived } from 'svelte/store'
import resolvePath from 'object-resolve-path'
import merge from 'deepmerge'
import {
capital,
title,
upper,
lower,
getClientLocale,
getGenericLocaleFrom,
getLocalesFrom,
} from './utils'
import { MessageObject, Formatter } from './types'
import {
getMessageFormatter,
getDateFormatter,
getNumberFormatter,
getTimeFormatter,
} from './formatters'
const $loading = writable(false)
let currentLocale: string
let currentDictionary: Record<string, Record<string, any>>
const dictQueue: Record<string, (() => Promise<any>)[]> = {}
const hasLocale = (locale: string) => locale in currentDictionary
async function registerLocaleLoader(locale: string, loader: any) {
if (!(locale in currentDictionary)) {
$dictionary.update(d => {
d[locale] = {}
return d
})
}
if (!(locale in dictQueue)) dictQueue[locale] = []
dictQueue[locale].push(loader)
}
function getAvailableLocale(locale: string): string | null {
if (locale in currentDictionary || locale == null) return locale
return getAvailableLocale(getGenericLocaleFrom(locale))
}
const lookupCache: Record<string, Record<string, string>> = {}
const addToCache = (path: string, locale: string, message: string) => {
if (!(locale in lookupCache)) lookupCache[locale] = {}
if (!(path in lookupCache[locale])) lookupCache[locale][path] = message
return message
}
const invalidateLookupCache = (locale: string) => {
delete lookupCache[locale]
}
const lookupMessage = (path: string, locale: string): string => {
if (locale == null) return null
if (locale in lookupCache && path in lookupCache[locale]) {
return lookupCache[locale][path]
}
if (hasLocale(locale)) {
if (path in currentDictionary[locale]) {
return addToCache(path, locale, currentDictionary[locale][path])
}
const message = resolvePath(currentDictionary[locale], path)
if (message) return addToCache(path, locale, message)
}
return lookupMessage(path, getGenericLocaleFrom(locale))
}
const formatMessage: Formatter = (id, options = {}) => {
if (typeof id === 'object') {
options = id as MessageObject
id = options.id
}
const { values, locale = currentLocale, default: defaultValue } = options
if (locale == null) {
throw new Error(
'[svelte-i18n] Cannot format a message without first setting the initial locale.'
)
}
const message = lookupMessage(id, locale)
if (!message) {
console.warn(
`[svelte-i18n] The message "${id}" was not found in "${getLocalesFrom(locale).join('", "')}".`
)
return defaultValue || id
}
if (!values) return message
return getMessageFormatter(message, locale).format(values)
}
formatMessage.time = (t, options) => getTimeFormatter(currentLocale, options).format(t)
formatMessage.date = (d, options) => getDateFormatter(currentLocale, options).format(d)
formatMessage.number = (n, options) => getNumberFormatter(currentLocale, options).format(n)
formatMessage.capital = (id, options) => capital(formatMessage(id, options))
formatMessage.title = (id, options) => title(formatMessage(id, options))
formatMessage.upper = (id, options) => upper(formatMessage(id, options))
formatMessage.lower = (id, options) => lower(formatMessage(id, options))
const $dictionary = writable<Record<string, Record<string, any>>>({})
$dictionary.subscribe(newDictionary => (currentDictionary = newDictionary))
function loadLocale(localeToLoad: string) {
return Promise.all(
getLocalesFrom(localeToLoad).map(localeItem =>
flushLocaleQueue(localeItem)
.then(() => [localeItem, { err: undefined }])
.catch(e => [localeItem, { err: e }])
)
)
}
async function flushLocaleQueue(locale: string = currentLocale) {
if (!(locale in dictQueue)) return
$loading.set(true)
return Promise.all(dictQueue[locale].map((loader: any) => loader()))
.then(partials => {
dictQueue[locale] = []
partials = partials.map(partial => partial.default || partial)
invalidateLookupCache(locale)
$dictionary.update(d => {
d[locale] = merge.all<any>([d[locale] || {}].concat(partials))
return d
})
})
.then(() => $loading.set(false))
}
const $locale = writable(null)
const localeSet = $locale.set
$locale.set = (newLocale: string): void | Promise<void> => {
const locale = getAvailableLocale(newLocale)
if (locale) {
return flushLocaleQueue(newLocale).then(() => localeSet(newLocale))
}
throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`)
}
$locale.update = (fn: (locale: string) => void | Promise<void>) => localeSet(fn(currentLocale))
$locale.subscribe((newLocale: string) => (currentLocale = newLocale))
const $format = derived([$locale, $dictionary], () => formatMessage)
const $locales = derived([$dictionary], ([$dictionary]) => Object.keys($dictionary))
import { MessageObject } from './types'
// defineMessages allow us to define and extract dynamic message ids
const defineMessages = (i: Record<string, MessageObject>) => i
export { customFormats, addCustomFormats } from './formatters'
export { $locale as locale, loadLocale as preloadLocale } from './stores/locale'
export {
$loading as loading,
$locale as locale,
$dictionary as dictionary,
$format as _,
$format as format,
$locales as locales,
getClientLocale,
defineMessages,
loadLocale as preloadLocale,
} from './stores/dictionary'
export { $loading as loading } from './stores/loading'
export { $format as format, $format as _, $format as t } from './stores/format'
// utilities
export { defineMessages, merge }
export { customFormats, addCustomFormats } from './includes/formats'
export { getClientLocale } from './includes/utils'
export {
flushQueue as waitLocale,
registerLocaleLoader as register,
flushLocaleQueue as waitLocale,
merge,
}
} from './includes/loaderQueue'

View File

@ -1 +0,0 @@
declare module 'object-resolve-path'

View File

@ -0,0 +1,22 @@
import { writable, derived } from 'svelte/store'
let dictionary: Record<string, Record<string, any>>
const $dictionary = writable<typeof dictionary>({})
$dictionary.subscribe(newDictionary => {
dictionary = newDictionary
})
function getDictionary() {
return dictionary
}
function hasLocaleDictionary(locale: string) {
return locale in dictionary
}
const $locales = derived([$dictionary], ([$dictionary]) =>
Object.keys($dictionary)
)
export { $dictionary, $locales, getDictionary, hasLocaleDictionary }

View File

@ -0,0 +1,55 @@
import { derived } from 'svelte/store'
import { Formatter, MessageObject } from '../types'
import { lookupMessage } from '../includes/lookup'
import { getLocalesFrom, capital, upper, lower, title } from '../includes/utils'
import {
getMessageFormatter,
getTimeFormatter,
getDateFormatter,
getNumberFormatter,
} from '../includes/formats'
import { getDictionary, $dictionary } from './dictionary'
import { getCurrentLocale, $locale } from './locale'
const formatMessage: Formatter = (id, options = {}) => {
if (typeof id === 'object') {
options = id as MessageObject
id = options.id
}
const { values, locale = getCurrentLocale(), default: defaultValue } = options
if (locale == null) {
throw new Error(
'[svelte-i18n] Cannot format a message without first setting the initial locale.'
)
}
const message = lookupMessage(getDictionary(), id, locale)
if (!message) {
console.warn(
`[svelte-i18n] The message "${id}" was not found in "${getLocalesFrom(
locale
).join('", "')}".`
)
return defaultValue || id
}
if (!values) return message
return getMessageFormatter(message, locale).format(values)
}
formatMessage.time = (t, options) => getTimeFormatter(options).format(t)
formatMessage.date = (d, options) => getDateFormatter(options).format(d)
formatMessage.number = (n, options) => getNumberFormatter(options).format(n)
formatMessage.capital = (id, options) => capital(formatMessage(id, options))
formatMessage.title = (id, options) => title(formatMessage(id, options))
formatMessage.upper = (id, options) => upper(formatMessage(id, options))
formatMessage.lower = (id, options) => lower(formatMessage(id, options))
const $format = derived([$locale, $dictionary], () => formatMessage)
export { $format }

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export const $loading = writable(false)

View File

@ -0,0 +1,46 @@
import { writable } from 'svelte/store'
import { getGenericLocaleFrom, getLocalesFrom } from '../includes/utils'
import { flushQueue } from '../includes/loaderQueue'
import { getDictionary } from './dictionary'
let current: string
const $locale = writable(null)
function getCurrentLocale() {
return current
}
function getAvailableLocale(locale: string): string | null {
if (locale in getDictionary() || locale == null) return locale
return getAvailableLocale(getGenericLocaleFrom(locale))
}
function loadLocale(localeToLoad: string) {
return Promise.all(
getLocalesFrom(localeToLoad).map(localeItem =>
flushQueue(localeItem)
.then(() => [localeItem, { err: undefined }])
.catch(e => [localeItem, { err: e }])
)
)
}
$locale.subscribe((newLocale: string) => {
current = newLocale
})
const localeSet = $locale.set
$locale.set = (newLocale: string): void | Promise<void> => {
if (getAvailableLocale(newLocale)) {
return flushQueue(newLocale).then(() => localeSet(newLocale))
}
throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`)
}
$locale.update = (fn: (locale: string) => void | Promise<void>) =>
localeSet(fn(current))
export { $locale, loadLocale, flushQueue, getCurrentLocale }

View File

@ -16,24 +16,28 @@ type IntlFormatterOptions<T> = T & {
}
export interface MemoizedIntlFormatter<T, U> {
(locale: string, options: IntlFormatterOptions<U>): T
(options: IntlFormatterOptions<U>): T
}
export interface Formatter extends FormatterFn {
time: (
d: Date | number,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>
) => string
date: (
d: Date | number,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>
) => string
number: (
d: number,
options?: IntlFormatterOptions<Intl.NumberFormatOptions>,
options?: IntlFormatterOptions<Intl.NumberFormatOptions>
) => string
capital: FormatterFn
title: FormatterFn
upper: FormatterFn
lower: FormatterFn
}
export interface LocaleLoader {
(): Promise<any>
}

2
src/client/types/modules.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module 'object-resolve-path'
declare module 'nano-memoize'

View File

@ -1,3 +1,4 @@
// todo this is a mess
import { Formatter } from '../../src/client/types'
import {
dictionary,
@ -7,8 +8,8 @@ import {
addCustomFormats,
customFormats,
preloadLocale,
registerLocaleLoader,
flushLocaleQueue,
register,
waitLocale,
} from '../../src/client'
global.Intl = require('intl')
@ -20,10 +21,10 @@ const dict = {
en: require('../fixtures/en.json'),
}
registerLocaleLoader('en-GB', () => import('../fixtures/en-GB.json'))
registerLocaleLoader('pt', () => import('../fixtures/pt.json'))
registerLocaleLoader('pt-BR', () => import('../fixtures/pt-BR.json'))
registerLocaleLoader('pt-PT', () => import('../fixtures/pt-PT.json'))
register('en-GB', () => import('../fixtures/en-GB.json'))
register('pt', () => import('../fixtures/pt.json'))
register('pt-BR', () => import('../fixtures/pt-BR.json'))
register('pt-PT', () => import('../fixtures/pt-PT.json'))
format.subscribe(formatFn => {
_ = formatFn
@ -34,11 +35,11 @@ locale.subscribe((l: string) => {
})
describe('locale', () => {
it('should change locale', () => {
locale.set('en')
it('should change locale', async () => {
await locale.set('en')
expect(currentLocale).toBe('en')
locale.set('en-US')
await locale.set('en-US')
expect(currentLocale).toBe('en-US')
})
@ -48,16 +49,6 @@ describe('locale', () => {
})
describe('dictionary', () => {
// todo test this better
it('allows to dynamically import a dictionary', async () => {
dictionary.update((dict: any) => {
dict.es = () => import('../fixtures/es.json')
return dict
})
await locale.set('es')
expect(currentLocale).toBe('es')
})
it('load a locale and its derived locales if dictionary is a loader', async () => {
const loaded = await preloadLocale('pt-PT')
expect(loaded[0][0]).toEqual('pt')
@ -65,28 +56,28 @@ describe('dictionary', () => {
})
it('load a partial dictionary and merge it with the existing one', async () => {
locale.set('en')
registerLocaleLoader('en', () => import('../fixtures/partials/en.json'))
await locale.set('en')
register('en', () => import('../fixtures/partials/en.json'))
expect(_('page.title_about')).toBe('page.title_about')
await flushLocaleQueue('en')
await waitLocale('en')
expect(_('page.title_about')).toBe('About')
})
})
describe('formatting', () => {
it('should translate to current locale', () => {
locale.set('en')
it('should translate to current locale', async () => {
await locale.set('en')
expect(_('switch.lang')).toBe('Switch language')
})
it('should fallback to message id if id is not found', () => {
locale.set('en')
it('should fallback to message id if id is not found', async () => {
await locale.set('en')
expect(_('batatinha.quente')).toBe('batatinha.quente')
})
it('should fallback to default value if id is not found', () => {
locale.set('en')
it('should fallback to default value if id is not found', async () => {
await locale.set('en')
expect(_('batatinha.quente', { default: 'Hot Potato' })).toBe('Hot Potato')
})
@ -95,23 +86,25 @@ describe('formatting', () => {
expect(_('sneakers', { locale: 'en-GB' })).toBe('trainers')
})
it('should fallback to generic locale XX if id not found in XX-YY', () => {
locale.set('en-GB')
it('should fallback to generic locale XX if id not found in XX-YY', async () => {
await locale.set('en-GB')
expect(_('switch.lang')).toBe('Switch language')
})
it('should accept single object with id prop as the message path', () => {
locale.set('en')
it('should accept single object with id prop as the message path', async () => {
await locale.set('en')
expect(_({ id: 'switch.lang' })).toBe('Switch language')
})
it('should translate to passed locale', () => {
expect(_('switch.lang', { locale: 'en' })).toBe('Switch language')
expect(_('switch.lang', { locale: 'pt' })).toBe('Trocar idioma')
})
it('should interpolate message with variables', () => {
locale.set('en')
expect(_('greeting.message', { values: { name: 'Chris' } })).toBe('Hello Chris, how are you?')
it('should interpolate message with variables', async () => {
await locale.set('en')
expect(_('greeting.message', { values: { name: 'Chris' } })).toBe(
'Hello Chris, how are you?'
)
})
})
@ -138,7 +131,9 @@ describe('utilities', () => {
})
it('should get the locale based on the navigator language', () => {
expect(getClientLocale({ navigator: true })).toBe(window.navigator.language)
expect(getClientLocale({ navigator: true })).toBe(
window.navigator.language
)
})
it('should get the fallback locale', () => {
@ -148,8 +143,8 @@ describe('utilities', () => {
})
describe('format utils', () => {
beforeAll(() => {
locale.set('en')
beforeAll(async () => {
await locale.set('en')
})
it('should capital a translated message', () => {
@ -169,11 +164,13 @@ describe('utilities', () => {
})
const date = new Date(2019, 3, 24, 23, 45)
it('should format a time value', () => {
locale.set('en')
it('should format a time value', async () => {
await locale.set('en')
expect(_.time(date)).toBe('11:45 PM')
expect(_.time(date, { format: 'medium' })).toBe('11:45:00 PM')
expect(_.time(date, { format: 'medium', locale: 'pt-BR' })).toBe('23:45:00')
expect(_.time(date, { format: 'medium', locale: 'pt-BR' })).toBe(
'23:45:00'
)
})
it('should format a date value', () => {
@ -188,8 +185,8 @@ describe('utilities', () => {
})
describe('custom formats', () => {
beforeAll(() => {
locale.set('pt-BR')
beforeAll(async () => {
await locale.set('pt-BR')
})
it('should have default number custom formats', () => {
@ -213,7 +210,7 @@ describe('custom formats', () => {
})
})
it('should format messages with custom formats', () => {
it('should format messages with custom formats', async () => {
addCustomFormats({
number: {
usd: { style: 'currency', currency: 'USD' },
@ -227,12 +224,16 @@ describe('custom formats', () => {
},
})
locale.set('en-US')
await locale.set('en-US')
expect(_.number(123123123, { format: 'usd' })).toContain('$123,123,123.00')
expect(_.date(new Date(2019, 0, 1), { format: 'customDate' })).toEqual('2019 AD')
expect(_.date(new Date(2019, 0, 1), { format: 'customDate' })).toEqual(
'2019 AD'
)
expect(_.time(new Date(2019, 0, 1, 2, 0, 0), { format: 'customTime' })).toEqual('Jan, 02')
expect(
_.time(new Date(2019, 0, 1, 2, 0, 0), { format: 'customTime' })
).toEqual('Jan, 02')
})
})

View File

@ -2104,6 +2104,11 @@ fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fast-memoize@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d"
integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g==
fb-watchman@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
@ -3453,11 +3458,6 @@ merge-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
micro-memoize@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/micro-memoize/-/micro-memoize-4.0.8.tgz#b2bd9fb57817fe5dc1eb1010b37b3f7695aef9a8"
integrity sha512-Mzlo15iWNrP5EwokGjx0Wlh2b3aMjTPdpsD+ryQtkYJBD67IxBddWU2fO3MIXRtXDH8NsuhaotTrtDbfb+k6jw==
micromatch@^3.1.10, micromatch@^3.1.4:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@ -3566,6 +3566,11 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
nano-memoize@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/nano-memoize/-/nano-memoize-1.1.7.tgz#e7850c73b95916b637224c4664a7bff18554c6ad"
integrity sha512-zUDNakN4iXDPerGRjnxkZmhhjMzuQipwMDuIPSrRNu+4lpiAt5pTxE5qoAXzMNv+11vYFfTqWRVEkCQ9hdSSZg==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"