{"version":3,"file":"index.mjs","names":[],"sources":["../src/utils.ts","../src/module.ts","../src/index.ts"],"sourcesContent":["/*\n * Copyright (c) 2023.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport { normalize } from 'pathe';\nimport process from 'node:process';\nimport type { FSStoreOptions, FSStoreOptionsInput } from './types';\n\nexport function normalizeOptions(input: FSStoreOptionsInput = {}): FSStoreOptions {\n    let directory: string[];\n    if (input.directory) {\n        directory = Array.isArray(input.directory) ?\n            input.directory :\n            [input.directory];\n\n        for (let i = 0; i < directory.length; i++) {\n            directory[i] = normalize(directory[i]);\n        }\n    } else {\n        directory = [process.cwd()];\n    }\n\n    const writeDirectory = normalize(input.writeDirectory ?? directory[0]);\n    const watch = input.watch === true;\n\n    return {\n        directory,\n        writeDirectory,\n        watch,\n    };\n}\n","/*\n * Copyright (c) 2023.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport { mkdir, rename, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport process from 'node:process';\nimport type { LocatorOptionsInput } from 'locter';\nimport {\n    load,\n    locateMany,\n} from 'locter';\nimport type { Merger } from 'smob';\nimport { createMerger } from 'smob';\nimport type {\n    IInvalidatingStore,\n    InvalidateListener,\n    Leaf,\n    NamespaceBodyInput,\n    StoreGetContext,\n    StoreSetContext,\n    Translations,\n} from 'ilingo';\nimport {\n    MemoryStore,\n    isBCP47LanguageCode,\n    normalizeNamespaceBody,\n} from 'ilingo';\nimport type { FSStoreOptionsInput } from './types';\nimport { normalizeOptions } from './utils';\n\ntype ChokidarLike = {\n    watch(paths: string | string[], options?: object): {\n        on(event: 'add' | 'change' | 'unlink', cb: (path: string) => void): unknown,\n        close(): Promise<void>,\n    },\n};\n\nexport class FSStore extends MemoryStore implements IInvalidatingStore {\n    protected loaded: Record<string, string[]>;\n\n    protected directories: string[];\n\n    protected writeDirectory: string;\n\n    protected merger: Merger;\n\n    protected listeners = new Set<InvalidateListener>();\n\n    /** Active chokidar watcher (only when `watch: true`). Closed by `close()`. */\n    protected watcher: ReturnType<ChokidarLike['watch']> | undefined;\n\n    constructor(input?: FSStoreOptionsInput) {\n        super({ id: input?.id ?? Symbol('FSStore'), data: [] });\n\n        const options = normalizeOptions(input);\n\n        this.loaded = {};\n        this.directories = options.directory;\n        this.writeDirectory = options.writeDirectory;\n\n        this.merger = createMerger({\n            inPlace: true,\n            array: true,\n            arrayDistinct: true,\n        });\n\n        if (options.watch) {\n            // chokidar is loaded asynchronously so that consumers who don't\n            // enable watch mode never pay the dep cost. We start the watcher\n            // promise-style; readiness is not awaited here (callers can\n            // await get()s freely — the cache will simply not be hot-\n            // invalidated until chokidar has signalled `ready`).\n            this.startWatcher().catch(() => { /* swallow — see startWatcher */ });\n        }\n    }\n\n    // ------------------------------------------\n    override async get(context: StoreGetContext): Promise<Leaf | undefined> {\n        await this.loadNamespace(context.namespace, context.locale);\n\n        return super.get(context);\n    }\n\n    override async set(context: StoreSetContext): Promise<void> {\n        // Ensure the in-memory record reflects the latest on-disk state\n        // before merging the new value, so the write does not drop sibling keys.\n        await this.loadNamespace(context.namespace, context.locale);\n\n        await super.set(context);\n\n        await this.persist(context.locale, context.namespace);\n    }\n\n    // ------------------------------------------\n\n    override async getLocales(): Promise<string[]> {\n        const locations = await locateMany(['*'], {\n            cwd: this.directories,\n            onlyDirectories: true,\n        });\n\n        return locations\n            .filter((location) => isBCP47LanguageCode(location.name))\n            .map((location) => location.name);\n    }\n\n    // ------------------------------------------\n\n    /**\n     * Drop cached file content for the matching scope. The next `get()` for\n     * an affected key will re-read from disk.\n     */\n    invalidate(locale?: string, namespace?: string): void {\n        if (typeof locale === 'undefined') {\n            this.loaded = {};\n            this.data = {};\n        } else if (typeof namespace === 'undefined') {\n            delete this.loaded[locale];\n            delete this.data[locale];\n        } else {\n            const namespaces = this.loaded[locale];\n            if (namespaces) {\n                this.loaded[locale] = namespaces.filter((g) => g !== namespace);\n            }\n            if (this.data[locale]) {\n                delete this.data[locale][namespace];\n            }\n        }\n        for (const listener of this.listeners) {\n            listener(locale, namespace);\n        }\n    }\n\n    on(event: 'invalidate', listener: InvalidateListener): () => void {\n        if (event !== 'invalidate') return () => {};\n        this.listeners.add(listener);\n        return () => { this.listeners.delete(listener); };\n    }\n\n    /**\n     * Stop the file watcher (if active) and detach all listeners. Idempotent.\n     * Useful in tests and on app shutdown — once closed, the store still\n     * serves cached reads but no longer reacts to file-system changes.\n     */\n    async close(): Promise<void> {\n        if (this.watcher) {\n            await this.watcher.close();\n            this.watcher = undefined;\n        }\n        this.listeners.clear();\n    }\n\n    // ------------------------------------------\n\n    protected isLoaded(namespace: string, locale: string): boolean {\n        this.loaded[locale] = this.loaded[locale] || [];\n\n        return this.loaded[locale].includes(namespace);\n    }\n\n    protected setIsLoaded(namespace: string, locale: string) {\n        this.loaded[locale] = this.loaded[locale] || [];\n\n        this.loaded[locale].push(namespace);\n    }\n\n    // ------------------------------------------\n\n    async loadNamespace(namespace: string, locale: string): Promise<Record<string, any>> {\n        // only load file once\n        if (this.isLoaded(namespace, locale)) {\n            /* istanbul ignore next */\n            return {};\n        }\n\n        this.initLines(namespace, locale);\n        this.setIsLoaded(namespace, locale);\n\n        const locations = await locateMany(\n            this.addExtensionPattern(namespace),\n            this.buildLocatorOptionsForLocale(locale),\n        );\n\n        const loadPromises = locations.map(\n            (location) => load(location)\n                .then((data) => (data && data.default ? data.default : data)),\n        );\n\n        const files = await Promise.all(loadPromises);\n        if (files.length === 0) {\n            return {};\n        }\n\n        this.data[locale][namespace] = this.mergeFiles(files);\n\n        return this.data[locale][namespace];\n    }\n\n    /**\n     * Persist the current in-memory record for `(locale, namespace)` to a JSON\n     * file inside `writeDirectory`. Always writes the merged record so that\n     * subsequent `loadNamespace` calls observe the change.\n     *\n     * Atomic via write-temp-then-rename. If the source data was originally a\n     * `.ts` / `.js` / `.cjs` file the original is left untouched and the new\n     * `.json` sits alongside it; on next load both are merged by smob and the\n     * new JSON keys win because the loader applies later sources on top.\n     *\n     * The file is written as a translations node (`{ type: 'translations', data }`) so it\n     * round-trips through `loadNamespace`, which expects that shape.\n     */\n    protected async persist(locale: string, namespace: string): Promise<void> {\n        const targetDir = path.join(this.writeDirectory, locale);\n        const targetFile = path.join(targetDir, `${namespace}.json`);\n        const tmpFile = `${targetFile}.${process.pid}.tmp`;\n\n        const record = (this.data[locale] && this.data[locale][namespace]) || {};\n        const content = `${JSON.stringify({ type: 'translations', data: record }, null, 4)}\\n`;\n\n        await mkdir(targetDir, { recursive: true });\n        await writeFile(tmpFile, content, 'utf8');\n        await rename(tmpFile, targetFile);\n    }\n\n    /**\n     * Start watching the configured directories. Each `(directory, locale,\n     * namespace)` file change calls `invalidate(locale, namespace)`. Errors loading\n     * chokidar (e.g. consumer hasn't installed the optional peer) throw a\n     * clear message — caught and rethrown as a deferred error so the\n     * constructor doesn't reject.\n     */\n    protected async startWatcher(): Promise<void> {\n        let chokidar: ChokidarLike;\n        try {\n            // Lazy import so the dep is loaded only when watch mode is on.\n            chokidar = await import('chokidar') as unknown as ChokidarLike;\n        } catch (err) {\n            // eslint-disable-next-line no-console\n            console.error(\n                '[ilingo/fs] watch: true requires the optional `chokidar` peer dependency. ' +\n                'Install it via `npm i chokidar -D`.',\n                err,\n            );\n            return;\n        }\n\n        this.watcher = chokidar.watch(this.directories, {\n            ignoreInitial: true,\n            persistent: false,\n        });\n        const onPath = (changedPath: string) => {\n            const parsed = this.parseLocaleNamespace(changedPath);\n            if (parsed) this.invalidate(parsed.locale, parsed.namespace);\n        };\n        this.watcher.on('add', onPath);\n        this.watcher.on('change', onPath);\n        this.watcher.on('unlink', onPath);\n    }\n\n    /**\n     * Map a file path under one of the configured directories to its\n     * `(locale, namespace)` pair. Returns `undefined` if the path doesn't sit\n     * exactly under `<dir>/<locale>/<namespace>.<ext>` (e.g. a deeper nesting,\n     * or a sibling file not owned by us).\n     */\n    protected parseLocaleNamespace(filePath: string): { locale: string, namespace: string } | undefined {\n        const absPath = path.resolve(filePath);\n        for (const dir of this.directories) {\n            const absDir = path.resolve(dir);\n            if (absPath.startsWith(`${absDir}${path.sep}`)) {\n                const rel = absPath.slice(absDir.length + 1);\n                const parts = rel.split(path.sep);\n                if (parts.length === 2) {\n                    const [locale, file] = parts;\n                    const dotIdx = file.lastIndexOf('.');\n                    if (dotIdx > 0 && isBCP47LanguageCode(locale)) {\n                        return { locale, namespace: file.slice(0, dotIdx) };\n                    }\n                }\n            }\n        }\n        return undefined;\n    }\n\n    protected buildLocatorOptionsForLocale(locale?: string): LocatorOptionsInput {\n        let directory: string[];\n        if (this.directories.length === 0) {\n            directory = [locale || 'en'];\n        } else {\n            directory = this.directories.map(\n                (directory) => path.join(directory, locale || 'en'),\n            );\n        }\n\n        return {\n            cwd: directory,\n            ignore: [],\n        };\n    }\n\n    protected addExtensionPattern(name: string) {\n        return `${name}.{js,mjs,cjs,ts,mts,json,conf}`;\n    }\n\n    protected mergeFiles(files: unknown[]) {\n        const translations: Translations = {};\n        for (const file of files) {\n            // Each file is a translations node — `{ type: 'translations', data }` (JSON) or\n            // `export default defineTranslations({ ... })` (TS/JS). Reduce it to the\n            // internal `Translations` shape and merge. A non-translations file normalizes to\n            // `{}` and emits a dev warning (see normalizeNamespaceBody).\n            this.merger(translations, normalizeNamespaceBody(file as NamespaceBodyInput));\n        }\n\n        return translations;\n    }\n}\n","/*\n * Copyright (c) 2023.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport { FSStore } from './module';\n\nexport * from './module';\nexport * from './types';\nexport default FSStore;\n"],"mappings":"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"}