Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/importable-api.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
52 changes: 52 additions & 0 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<string>> = [];

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" } });
});
});
18 changes: 16 additions & 2 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
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>) => 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
*/
export function updateLabelCache({
cache,
source,
data,
fallback = defaultFallback,
}: {
cache: LabelData;
source: LabelData;
data: Record<string, Set<string>>;
fallback?: LabelFallback;
}) {
for (const [key, values] of Object.entries(data)) {
// Next-intl uses dot notation for nested objects
Expand Down Expand Up @@ -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]);
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
55 changes: 55 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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 <button>{t("submit")}</button>;
}
`;

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<string> = [];

await buildLabels({ input, onFile: (file) => seen.push(file) });

expect(seen).toHaveLength(1);
expect(seen[0]).toContain("component.tsx");
});
});
102 changes: 74 additions & 28 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
export async function buildLabels({
input,
source = {},
fallback,
onFile,
}: BuildLabelsOptions): Promise<LabelData> {
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,
Expand All @@ -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<void> {
// 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<LabelData> {
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;
}
}

/**
Expand Down
Loading