diff --git a/.prettierrc b/.prettierrc index beaab0d..9b4e1ca 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "semi": false, - "printWidth": 100, + "printWidth": 80, "trailingComma": "es5", "bracketSpacing": true, "jsxBracketSameLine": false, diff --git a/example/rollup.config.js b/example/rollup.config.js index e977918..2ee80ca 100644 --- a/example/rollup.config.js +++ b/example/rollup.config.js @@ -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, }, diff --git a/example/src/server.js b/example/src/server.js index 86f7466..056b74d 100644 --- a/example/src/server.js +++ b/example/src/server.js @@ -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) diff --git a/example/src/service-worker.js b/example/src/service-worker.js index 2289a55..1578336 100644 --- a/example/src/service-worker.js +++ b/example/src/service-worker.js @@ -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 + } + }) + ) +}) diff --git a/example/yarn.lock b/example/yarn.lock index 54b36bf..32f0a46 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -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" "*" diff --git a/package.json b/package.json index 73a308a..f29ec80 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/cli/extract.ts b/src/cli/extract.ts index 331c12f..dde5868 100644 --- a/src/cli/extract.ts +++ b/src/cli/extract.ts @@ -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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 58c40e9..70133f1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 ', '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') diff --git a/src/client/formatters.ts b/src/client/includes/formats.ts similarity index 70% rename from src/client/formatters.ts rename to src/client/includes/formats.ts index 791385b..93029ba 100644 --- a/src/client/formatters.ts +++ b/src/client/includes/formats.ts @@ -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) { 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) ) diff --git a/src/client/includes/loaderQueue.ts b/src/client/includes/loaderQueue.ts new file mode 100644 index 0000000..3d7df88 --- /dev/null +++ b/src/client/includes/loaderQueue.ts @@ -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> = {} + +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([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) +} diff --git a/src/client/includes/lookup.ts b/src/client/includes/lookup.ts new file mode 100644 index 0000000..47b1c5a --- /dev/null +++ b/src/client/includes/lookup.ts @@ -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> = {} + +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)) + ) +} diff --git a/src/client/utils.ts b/src/client/includes/utils.ts similarity index 74% rename from src/client/utils.ts rename to src/client/includes/utils.ts index 126f8e6..48b7d17 100644 --- a/src/client/utils.ts +++ b/src/client/includes/utils.ts @@ -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, diff --git a/src/client/index.ts b/src/client/index.ts index 7d8ee25..0888603 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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> -const dictQueue: Record Promise)[]> = {} - -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> = {} -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>>({}) -$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([d[locale] || {}].concat(partials)) - return d - }) - }) - .then(() => $loading.set(false)) -} - -const $locale = writable(null) -const localeSet = $locale.set -$locale.set = (newLocale: string): void | Promise => { - 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) => 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) => 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' diff --git a/src/client/modules.d.ts b/src/client/modules.d.ts deleted file mode 100644 index 1c95bf8..0000000 --- a/src/client/modules.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'object-resolve-path' \ No newline at end of file diff --git a/src/client/stores/dictionary.ts b/src/client/stores/dictionary.ts new file mode 100644 index 0000000..c6aef6a --- /dev/null +++ b/src/client/stores/dictionary.ts @@ -0,0 +1,22 @@ +import { writable, derived } from 'svelte/store' + +let dictionary: Record> + +const $dictionary = writable({}) +$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 } diff --git a/src/client/stores/format.ts b/src/client/stores/format.ts new file mode 100644 index 0000000..3ae5c60 --- /dev/null +++ b/src/client/stores/format.ts @@ -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 } diff --git a/src/client/stores/loading.ts b/src/client/stores/loading.ts new file mode 100644 index 0000000..41afb6a --- /dev/null +++ b/src/client/stores/loading.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export const $loading = writable(false) diff --git a/src/client/stores/locale.ts b/src/client/stores/locale.ts new file mode 100644 index 0000000..800ee9b --- /dev/null +++ b/src/client/stores/locale.ts @@ -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 => { + if (getAvailableLocale(newLocale)) { + return flushQueue(newLocale).then(() => localeSet(newLocale)) + } + + throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`) +} + +$locale.update = (fn: (locale: string) => void | Promise) => + localeSet(fn(current)) + +export { $locale, loadLocale, flushQueue, getCurrentLocale } diff --git a/src/client/types.ts b/src/client/types/index.ts similarity index 69% rename from src/client/types.ts rename to src/client/types/index.ts index 92799bb..5e2d787 100644 --- a/src/client/types.ts +++ b/src/client/types/index.ts @@ -16,24 +16,28 @@ type IntlFormatterOptions = T & { } export interface MemoizedIntlFormatter { - (locale: string, options: IntlFormatterOptions): T + (options: IntlFormatterOptions): T } export interface Formatter extends FormatterFn { time: ( d: Date | number, - options?: IntlFormatterOptions, + options?: IntlFormatterOptions ) => string date: ( d: Date | number, - options?: IntlFormatterOptions, + options?: IntlFormatterOptions ) => string number: ( d: number, - options?: IntlFormatterOptions, + options?: IntlFormatterOptions ) => string capital: FormatterFn title: FormatterFn upper: FormatterFn lower: FormatterFn } + +export interface LocaleLoader { + (): Promise +} diff --git a/src/client/types/modules.d.ts b/src/client/types/modules.d.ts new file mode 100644 index 0000000..e9a9a3a --- /dev/null +++ b/src/client/types/modules.d.ts @@ -0,0 +1,2 @@ +declare module 'object-resolve-path' +declare module 'nano-memoize' diff --git a/test/client/index.test.ts b/test/client/index.test.ts index cbbec40..dc1800c 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -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') }) }) diff --git a/yarn.lock b/yarn.lock index 779fad2..219791f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"