diff --git a/.changeset/importable-api.md b/.changeset/importable-api.md new file mode 100644 index 0000000..433b308 --- /dev/null +++ b/.changeset/importable-api.md @@ -0,0 +1,11 @@ +--- +"@labdigital/intl-extractor": minor +--- + +Expose the extractor as a library, not just a CLI. + +- Declare an `exports` map, so `@labdigital/intl-extractor` can be imported. `dist/index.js` was already built and shipped; nothing referenced it. +- Add `buildLabels({ input, source, fallback, onFile })`, which returns the label tree without touching the output file. `processFiles` becomes the read-merge-write wrapper around it. +- Add a `fallback` option for labels with no value in the source. It receives the full key path and defaults to the label name, so existing behaviour is unchanged. + +Two fixes while in there: `processFiles` did not `await` its write, and its "no existing source file" branch was unreachable because the read threw first. diff --git a/README.md b/README.md index ffeccc6..e0735ed 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,22 @@ This automates away manually setting labels in a `source.json` file. npx @labdigital/intl-extractor -i ./path/to/files -o ./path/to/output.json ``` +## Programmatic use + +```ts +import { buildLabels } from "@labdigital/intl-extractor"; + +const labels = await buildLabels({ + input: "./src", + source: existingLabels, // values to keep + fallback: (path) => `[${path.at(-1)}]`, // value for a label with no entry yet +}); +``` + +`buildLabels` returns the tree and writes nothing, so you can merge it with labels from +elsewhere before deciding what the output file should be. `processFiles` is the +read-merge-write wrapper the CLI uses. + ## How it works Scans input files for `useTranslations` or `getTranslations` usage using the TypeScript SDK. It will then merge them all together and check the source JSON file for label values. diff --git a/package.json b/package.json index a434d85..064769c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,14 @@ "license": "MIT", "author": "Lab Digital", "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "intl-extractor": "./dist/bin/cli.js" }, diff --git a/src/cache.test.ts b/src/cache.test.ts index e4dd0b1..43941c2 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -81,3 +81,55 @@ describe("cache", () => { }); }); }); + +describe("cache fallback", () => { + test("defaults to the label name", () => { + const cache = {}; + + updateLabelCache({ + cache, + data: { Test: new Set(["hello", "nested.deep"]) }, + source: {}, + }); + + expect(cache).toEqual({ + Test: { hello: "hello", nested: { deep: "deep" } }, + }); + }); + + test("uses a custom fallback, receiving the full key path", () => { + const cache = {}; + const seen: Array> = []; + + updateLabelCache({ + cache, + data: { Test: new Set(["hello", "nested.deep"]) }, + source: {}, + fallback: (path) => { + seen.push(path); + return `[${path[path.length - 1]}]`; + }, + }); + + expect(cache).toEqual({ + Test: { hello: "[hello]", nested: { deep: "[deep]" } }, + }); + expect(seen).toEqual([ + ["Test", "hello"], + ["Test", "nested", "deep"], + ]); + }); + + test("does not fall back when the source has a value", () => { + const cache = {}; + + updateLabelCache({ + cache, + data: { Test: new Set(["hello"]) }, + source: { Test: { hello: "Hallo" } }, + fallback: () => "[unused]", + }); + + expect(cache).toEqual({ Test: { hello: "Hallo" } }); + }); +}); diff --git a/src/cache.ts b/src/cache.ts index f0c57f4..b074718 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,5 +1,15 @@ import type { LabelData } from "./types"; +/** + * Produces the value for a label that has no entry in the source yet. Receives + * the full key path (namespace segments followed by label segments), so a caller + * can mark the value as untranslated however it likes — e.g. `[label]`. + */ +export type LabelFallback = (path: Array) => string; + +/** Default: the last path segment, i.e. the label's own name. */ +const defaultFallback: LabelFallback = (path) => path[path.length - 1] ?? ""; + /** * Update existing label cache based on given data and source labels */ @@ -7,10 +17,12 @@ export function updateLabelCache({ cache, source, data, + fallback = defaultFallback, }: { cache: LabelData; source: LabelData; data: Record>; + fallback?: LabelFallback; }) { for (const [key, values] of Object.entries(data)) { // Next-intl uses dot notation for nested objects @@ -40,11 +52,13 @@ export function updateLabelCache({ // The last key should be a string value, not an object const lastKey = valueKey[valueKey.length - 1]; currentNestedCache[lastKey] = - getLabelFromData(source, [...keys, value]) || lastKey; + getLabelFromData(source, [...keys, value]) || + fallback([...keys, ...valueKey]); } else { // For non-nested keys, simply add the value currentNestedCache[value] = - getLabelFromData(source, [...keys, value]) || value; + getLabelFromData(source, [...keys, value]) || + fallback([...keys, value]); } } } diff --git a/src/index.ts b/src/index.ts index c431d9b..1dd3af8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,4 @@ +export type { LabelFallback } from "./cache"; export { extractLabels, extractLabelsFromFile } from "./extract"; +export { type BuildLabelsOptions, buildLabels, processFiles } from "./main"; +export type { LabelData } from "./types"; diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..fbb4445 --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { buildLabels } from "./main"; + +const withSource = async (contents: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), "intl-extractor-")); + await writeFile(join(dir, "component.tsx"), contents, "utf8"); + return dir; +}; + +const COMPONENT = ` +import { useTranslations } from "next-intl"; + +export function Cart() { + const t = useTranslations("Cart"); + return ; +} +`; + +describe("buildLabels", () => { + test("returns the tree without writing anything", async () => { + const input = await withSource(COMPONENT); + + expect(await buildLabels({ input })).toEqual({ + Cart: { submit: "submit" }, + }); + }); + + test("takes values from source and falls back for the rest", async () => { + const input = await withSource(COMPONENT); + + expect( + await buildLabels({ + input, + source: { Cart: { submit: "Add to cart" } }, + }), + ).toEqual({ Cart: { submit: "Add to cart" } }); + + expect( + await buildLabels({ input, fallback: (p) => `[${p.at(-1)}]` }), + ).toEqual({ Cart: { submit: "[submit]" } }); + }); + + test("reports each contributing file", async () => { + const input = await withSource(COMPONENT); + const seen: Array = []; + + await buildLabels({ input, onFile: (file) => seen.push(file) }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toContain("component.tsx"); + }); +}); diff --git a/src/main.ts b/src/main.ts index 475d036..9bfba64 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,36 +1,35 @@ import * as fs from "node:fs"; import * as glob from "glob"; -import { updateLabelCache } from "./cache"; +import { type LabelFallback, updateLabelCache } from "./cache"; import { extractLabelsFromFile } from "./extract"; import type { LabelData } from "./types"; +export type BuildLabelsOptions = { + /** Root path of typescript files to scan. */ + input: string; + /** Existing labels to take values from. Anything absent gets `fallback`. */ + source?: LabelData; + /** Value for a label with no entry in `source`. Defaults to the label name. */ + fallback?: LabelFallback; + /** Called for each file that contributed labels. */ + onFile?: (file: string) => void; +}; + /** - * Main function that collects labels, source file and writes it to the output - * @param rootPath Root path of typescript files to check - * @param output JSON file to use for output labels + * Scan `input` for `useTranslations` / `getTranslations` usage and return the + * label tree, taking values from `source` where it has them. Does no file IO on + * the result — use it to compose the output yourself; {@link processFiles} is + * the read-merge-write wrapper the CLI uses. */ -export async function processFiles( - input: string, - output: string, -): Promise { +export async function buildLabels({ + input, + source = {}, + fallback, + onFile, +}: BuildLabelsOptions): Promise { const cache: LabelData = {}; const pattern = "**/*.{ts,tsx}"; - // The source file with existing labels should be the current output - const sourceFile = await fs.promises.readFile(output, "utf8"); - - if (!sourceFile) { - console.info("No existing source file found, will build from scratch"); - } - - let source: LabelData; - try { - source = JSON.parse(sourceFile) as unknown as LabelData; - } catch (err) { - console.error(`Error parsing source file: ${output}`); - throw err; - } - // Collect list of files based on given directory to check const files = glob.sync(pattern, { cwd: input, @@ -42,15 +41,62 @@ export async function processFiles( // Update cache if we get results from a file if (Object.keys(data).length > 0) { - console.info(`Updating labels for ${file}`); - // This might not be performant as we do existign source look ups for every added file - updateLabelCache({ cache, data, source }); + onFile?.(file); + // This might not be performant as we do existing source look ups for every added file + updateLabelCache({ cache, data, source, fallback }); } } + return deepSortObject(cache); +} + +/** + * Main function that collects labels, source file and writes it to the output + * @param input Root path of typescript files to check + * @param output JSON file to use for output labels + */ +export async function processFiles( + input: string, + output: string, +): Promise { + // The source file with existing labels should be the current output + const source = await readSource(output); + + const labels = await buildLabels({ + input, + source, + onFile: (file) => { + console.info(`Updating labels for ${file}`); + }, + }); + // Write the new output - const sorted = deepSortObject(cache); - fs.promises.writeFile(output, `${JSON.stringify(sorted, null, "\t")}\n`); + await fs.promises.writeFile( + output, + `${JSON.stringify(labels, null, "\t")}\n`, + ); +} + +/** Read the existing output file, treating a missing one as no labels yet. */ +async function readSource(output: string): Promise { + let contents: string; + try { + contents = await fs.promises.readFile(output, "utf8"); + } catch { + console.info("No existing source file found, will build from scratch"); + return {}; + } + + if (!contents.trim()) { + return {}; + } + + try { + return JSON.parse(contents) as LabelData; + } catch (err) { + console.error(`Error parsing source file: ${output}`); + throw err; + } } /**