babel-plugin-saykit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ let node_path = require("node:path");
2
+ let node_fs = require("node:fs");
3
+ let _saykit_config_features_catalogue = require("@saykit/config/features/catalogue");
4
+ //#region src/catalogue.ts
5
+ /** Normalise an absolute path into the form bucket globs are written against. */
6
+ const toId = (path) => (0, node_path.relative)(process.cwd(), path).replaceAll("\\", "/").split("?")[0];
7
+ /**
8
+ * The glob a bucket's catalogues match, e.g. `src/locales/*.po` — the `output`
9
+ * template with its placeholders filled in. Bundlers that select files by glob
10
+ * rather than by predicate need this to target exactly the catalogues, and
11
+ * nothing else sharing their extension.
12
+ */
13
+ const catalogueGlob = (bucket) => String(bucket.output).replace("{locale}", "*").replace("{extension}", bucket.formatter.extension.slice(1)).replaceAll("\\", "/").replace(/^\.\//, "");
14
+ /** Whether `path` is one of the config's catalogue outputs. */
15
+ const isCatalogue = (config, path) => config.buckets.some((bucket) => bucket.output.match(toId(path)));
16
+ /**
17
+ * Assemble the catalogue at `path` into a `{ id: string }` record, merging in
18
+ * the fallback chain (configured fallbacks + the source locale) so an
19
+ * untranslated key resolves to a fallback string rather than going missing.
20
+ *
21
+ * Returns `undefined` when the path is not a catalogue, and otherwise reports
22
+ * the files that fed the record alongside it — a bundler that can track them
23
+ * gets invalidation for free when a fallback locale is edited.
24
+ *
25
+ * Reads are synchronous so every caller can share one implementation: Babel's
26
+ * visitor cannot await, and the handful of files in a fallback chain are not
27
+ * worth an async path in a bundler that is blocked on the result anyway.
28
+ */
29
+ function loadCatalogue(config, path) {
30
+ const id = toId(path);
31
+ const bucket = config.buckets.find((b) => b.output.match(id));
32
+ if (!bucket) return;
33
+ const { sources } = (0, _saykit_config_features_catalogue.resolveCatalogueSources)(config, bucket, id);
34
+ return {
35
+ record: (0, _saykit_config_features_catalogue.assembleCatalogueRecord)(bucket, sources.map((source) => {
36
+ try {
37
+ return (0, node_fs.readFileSync)(source, "utf8");
38
+ } catch {
39
+ return "";
40
+ }
41
+ })),
42
+ sources
43
+ };
44
+ }
45
+ //#endregion
46
+ Object.defineProperty(exports, "catalogueGlob", {
47
+ enumerable: true,
48
+ get: function() {
49
+ return catalogueGlob;
50
+ }
51
+ });
52
+ Object.defineProperty(exports, "isCatalogue", {
53
+ enumerable: true,
54
+ get: function() {
55
+ return isCatalogue;
56
+ }
57
+ });
58
+ Object.defineProperty(exports, "loadCatalogue", {
59
+ enumerable: true,
60
+ get: function() {
61
+ return loadCatalogue;
62
+ }
63
+ });
package/dist/index.cjs CHANGED
@@ -1,44 +1,29 @@
1
- let node_fs = require("node:fs");
1
+ const require_catalogue = require("./catalogue-B3HA3aVz.cjs");
2
2
  let node_path = require("node:path");
3
3
  let _babel_core = require("@babel/core");
4
- let _saykit_config_features_catalogue = require("@saykit/config/features/catalogue");
5
4
  let _saykit_config_features_loader = require("@saykit/config/features/loader");
6
5
  //#region src/index.ts
7
- var src_default = (api) => {
6
+ var src_default = (_, { catalogues = "inline" } = {}) => {
8
7
  const config = (0, _saykit_config_features_loader.resolveConfig)();
9
8
  return {
10
9
  name: "saykit",
11
- parserOverride(code, opts, parse) {
12
- const id_ = opts.sourceFileName;
13
- if (!id_ || id_.includes("node_modules")) return parse(code, opts);
14
- const id = (0, node_path.relative)(process.cwd(), id_).replaceAll("\\", "/").split("?")[0];
15
- return parse(config.buckets.find((b) => b.match(id))?.transformer.transform(code, id) ?? code, opts);
16
- },
17
- visitor: { ImportDeclaration(path, state) {
10
+ visitor: catalogues === "module" ? {} : { ImportDeclaration(path, state) {
18
11
  const importee = path.node.source.value;
19
12
  if (!importee.startsWith(".")) return;
20
13
  const importer = state.filename ?? state.file.opts.filename;
21
14
  if (!importer) return;
22
- const id_ = (0, node_path.resolve)((0, node_path.dirname)(importer), importee);
15
+ const catalogue = require_catalogue.loadCatalogue(config, (0, node_path.resolve)((0, node_path.dirname)(importer), importee));
16
+ if (!catalogue) return;
17
+ const [specifier, ...rest] = path.node.specifiers;
18
+ if (specifier?.type !== "ImportDefaultSpecifier" || rest.length > 0) throw path.buildCodeFrameError("SayKit inline imports require a single default import");
19
+ path.replaceWith(_babel_core.types.variableDeclaration("const", [_babel_core.types.variableDeclarator(_babel_core.types.identifier(specifier.local.name), _babel_core.types.objectExpression(Object.entries(catalogue.record).map(([key, value]) => _babel_core.types.objectProperty(_babel_core.types.stringLiteral(key), _babel_core.types.stringLiteral(value)))))]));
20
+ } },
21
+ parserOverride(code, opts, parse) {
22
+ const id_ = opts.sourceFileName;
23
+ if (!id_ || id_.includes("node_modules")) return parse(code, opts);
23
24
  const id = (0, node_path.relative)(process.cwd(), id_).replaceAll("\\", "/").split("?")[0];
24
- const bucket = config.buckets.find((b) => b.output.match(id));
25
- if (!bucket) return;
26
- const specifier = path.node.specifiers.find((s) => s.type === "ImportDefaultSpecifier");
27
- if (!specifier) throw path.buildCodeFrameError("SayKit inline imports require a default import");
28
- const { sources } = (0, _saykit_config_features_catalogue.resolveCatalogueSources)(config, bucket, id);
29
- const contents = sources.map((source) => {
30
- try {
31
- return (0, node_fs.readFileSync)(source, "utf8");
32
- } catch {
33
- return "";
34
- }
35
- });
36
- for (const source of sources) try {
37
- api.addExternalDependency(source);
38
- } catch {}
39
- const entries = Object.entries((0, _saykit_config_features_catalogue.assembleCatalogueRecord)(bucket, contents));
40
- path.replaceWith(_babel_core.types.variableDeclaration("const", [_babel_core.types.variableDeclarator(_babel_core.types.identifier(specifier.local.name), _babel_core.types.objectExpression(entries.map(([key, value]) => _babel_core.types.objectProperty(_babel_core.types.stringLiteral(key), _babel_core.types.stringLiteral(value)))))]));
41
- } }
25
+ return parse(config.buckets.find((b) => b.match(id))?.transformer.transform(code, id) ?? code, opts);
26
+ }
42
27
  };
43
28
  };
44
29
  //#endregion
package/dist/index.d.cts CHANGED
@@ -1,12 +1,26 @@
1
- import { PluginAPI, PluginObj, parse } from "@babel/core";
1
+ import { PluginObj, parse } from "@babel/core";
2
2
  //#region src/index.d.ts
3
3
  declare module '@babel/core' {
4
- interface PluginAPI {
5
- addExternalDependency(ref: string): void;
6
- }
7
4
  interface PluginObj {
8
5
  parserOverride(code: string, opts: TransformOptions, parse: typeof parse): ParseResult | null;
9
6
  }
10
7
  }
11
- declare const _default: (api: PluginAPI) => PluginObj;
12
- export = _default;
8
+ interface Options {
9
+ /**
10
+ * How a catalogue import is resolved.
11
+ *
12
+ * `'inline'` (the default) replaces the import with the assembled record, so
13
+ * the Babel plugin is enough on its own. The cost is that the record lands in
14
+ * the importing module, which a bundler only re-reads when that module's own
15
+ * bytes change — editing a catalogue will not hot-reload.
16
+ *
17
+ * `'module'` leaves the import for a bundler integration to serve —
18
+ * `babel-plugin-saykit/next` or `babel-plugin-saykit/metro`. Set this
19
+ * whenever one of those is wired up, or the import gets inlined before the
20
+ * integration is ever asked for the module.
21
+ */
22
+ catalogues?: 'inline' | 'module';
23
+ }
24
+ declare const _default: (_: unknown, { catalogues }?: Options) => PluginObj;
25
+ //#endregion
26
+ export { Options, _default as default };
@@ -0,0 +1,57 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_path = require("node:path");
3
+ let _saykit_config_features_loader = require("@saykit/config/features/loader");
4
+ let node_module = require("node:module");
5
+ //#region src/metro/index.ts
6
+ /**
7
+ * Resolve Metro's own transformer from the project rather than from this
8
+ * package, since it is the project that depends on Metro.
9
+ *
10
+ * The base is the config's `projectRoot` — `process.cwd()` is wrong whenever
11
+ * Metro is started from elsewhere, which in a monorepo is the normal case.
12
+ */
13
+ const transformerPath = (0, node_path.join)(__dirname, "transformer.cjs");
14
+ function resolveUpstream(specifier, projectRoot) {
15
+ if ((0, node_path.isAbsolute)(specifier)) return specifier;
16
+ try {
17
+ return (0, node_module.createRequire)((0, node_path.join)(projectRoot, "metro.config.js")).resolve(specifier);
18
+ } catch {
19
+ return require.resolve(specifier);
20
+ }
21
+ }
22
+ /**
23
+ * Wrap a Metro config so SayKit catalogues load as real modules.
24
+ *
25
+ * ```js
26
+ * const { withSayKit } = require('babel-plugin-saykit/metro');
27
+ * module.exports = withSayKit(getDefaultConfig(__dirname));
28
+ * ```
29
+ *
30
+ * Metro's transform cache is keyed on each file's own bytes, so a catalogue has
31
+ * to stay a module of its own to be invalidated at all — see
32
+ * `./transformer.ts`.
33
+ *
34
+ * Apply this outermost. Anything wrapped around it that also sets
35
+ * `transformerPath` replaces this one, and catalogues stop being assembled.
36
+ */
37
+ function withSayKit(metroConfig) {
38
+ if (metroConfig.transformerPath === transformerPath) return metroConfig;
39
+ const extensions = (0, _saykit_config_features_loader.resolveConfig)().buckets.map((bucket) => bucket.formatter.extension.slice(1)).filter((extension) => extension !== "json");
40
+ const sourceExts = [...metroConfig.resolver?.sourceExts ?? []];
41
+ for (const extension of extensions) if (!sourceExts.includes(extension)) sourceExts.push(extension);
42
+ const upstream = metroConfig.transformerPath ?? "metro-transform-worker";
43
+ return {
44
+ ...metroConfig,
45
+ resolver: {
46
+ ...metroConfig.resolver,
47
+ sourceExts
48
+ },
49
+ transformerPath,
50
+ transformer: {
51
+ ...metroConfig.transformer,
52
+ saykitTransformerPath: resolveUpstream(upstream, metroConfig.projectRoot ?? process.cwd())
53
+ }
54
+ };
55
+ }
56
+ //#endregion
57
+ exports.withSayKit = withSayKit;
@@ -0,0 +1,27 @@
1
+ //#region src/metro/index.d.ts
2
+ interface MetroConfig {
3
+ projectRoot?: string;
4
+ resolver?: {
5
+ sourceExts?: string[];
6
+ };
7
+ transformer?: Record<string, unknown>;
8
+ transformerPath?: string;
9
+ }
10
+ /**
11
+ * Wrap a Metro config so SayKit catalogues load as real modules.
12
+ *
13
+ * ```js
14
+ * const { withSayKit } = require('babel-plugin-saykit/metro');
15
+ * module.exports = withSayKit(getDefaultConfig(__dirname));
16
+ * ```
17
+ *
18
+ * Metro's transform cache is keyed on each file's own bytes, so a catalogue has
19
+ * to stay a module of its own to be invalidated at all — see
20
+ * `./transformer.ts`.
21
+ *
22
+ * Apply this outermost. Anything wrapped around it that also sets
23
+ * `transformerPath` replaces this one, and catalogues stop being assembled.
24
+ */
25
+ declare function withSayKit<T extends MetroConfig>(metroConfig: T): T;
26
+ //#endregion
27
+ export { withSayKit };
@@ -0,0 +1,38 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_catalogue = require("../catalogue-B3HA3aVz.cjs");
3
+ let node_path = require("node:path");
4
+ let _saykit_config_features_loader = require("@saykit/config/features/loader");
5
+ let node_fs = require("node:fs");
6
+ let node_crypto = require("node:crypto");
7
+ //#region src/metro/transformer.ts
8
+ const config = (0, _saykit_config_features_loader.resolveConfig)();
9
+ const upstream = (transformerConfig) => require(transformerConfig.saykitTransformerPath);
10
+ /**
11
+ * What a catalogue transforms into depends on the SayKit config — the fallback
12
+ * chain it resolves, the formatter that parses it — and on the version of this
13
+ * package doing the assembling. Neither is a byte of the file Metro hashes, so
14
+ * without them in the cache key, editing `saykit.config.*` or upgrading leaves
15
+ * every catalogue serving the record it was cached with.
16
+ */
17
+ const salt = (0, node_crypto.createHash)("sha1").update((0, node_fs.readFileSync)((0, node_path.join)(__dirname, "..", "..", "package.json"), "utf8")).update((0, node_fs.readFileSync)((0, _saykit_config_features_loader.resolveConfigFile)(), "utf8")).digest("hex");
18
+ /**
19
+ * Metro reads `.json` straight through `transformJSON` and never runs Babel over
20
+ * it, so a Babel plugin cannot reach a catalogue at all. Wrapping the transform
21
+ * worker is the one place that can: it substitutes the assembled record for the
22
+ * catalogue's own source, leaving the file a real module whose sha1 — and
23
+ * therefore Metro's transform cache entry — moves whenever it is edited.
24
+ */
25
+ function transform(transformerConfig, projectRoot, filename, data, options) {
26
+ const worker = upstream(transformerConfig);
27
+ const catalogue = require_catalogue.loadCatalogue(config, filename);
28
+ if (!catalogue) return worker.transform(transformerConfig, projectRoot, filename, data, options);
29
+ const record = JSON.stringify(catalogue.record);
30
+ const code = filename.endsWith(".json") ? record : `module.exports = ${record};`;
31
+ return worker.transform(transformerConfig, projectRoot, filename, Buffer.from(code), options);
32
+ }
33
+ function getCacheKey(transformerConfig, options) {
34
+ return `${upstream(transformerConfig).getCacheKey(transformerConfig, options)}-saykit-${salt}`;
35
+ }
36
+ //#endregion
37
+ exports.getCacheKey = getCacheKey;
38
+ exports.transform = transform;
@@ -0,0 +1,20 @@
1
+ //#region src/metro/transformer.d.ts
2
+ /**
3
+ * The transformer config Metro hands each worker call. `saykitTransformerPath`
4
+ * is the upstream worker this one wraps, stashed here by {@link withSayKit}
5
+ * because a worker is loaded standalone and has no other way to reach it.
6
+ */
7
+ interface TransformerConfig {
8
+ saykitTransformerPath: string;
9
+ }
10
+ /**
11
+ * Metro reads `.json` straight through `transformJSON` and never runs Babel over
12
+ * it, so a Babel plugin cannot reach a catalogue at all. Wrapping the transform
13
+ * worker is the one place that can: it substitutes the assembled record for the
14
+ * catalogue's own source, leaving the file a real module whose sha1 — and
15
+ * therefore Metro's transform cache entry — moves whenever it is edited.
16
+ */
17
+ declare function transform(transformerConfig: TransformerConfig, projectRoot: string, filename: string, data: Buffer, options: unknown): Promise<unknown>;
18
+ declare function getCacheKey(transformerConfig: TransformerConfig, options?: unknown): string;
19
+ //#endregion
20
+ export { getCacheKey, transform };
@@ -0,0 +1,51 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_catalogue = require("../catalogue-B3HA3aVz.cjs");
3
+ let _saykit_config_features_loader = require("@saykit/config/features/loader");
4
+ //#region src/next/index.ts
5
+ const loader = "babel-plugin-saykit/next/loader";
6
+ /**
7
+ * Wrap a Next config so SayKit catalogues load as real modules.
8
+ *
9
+ * ```js
10
+ * import { withSayKit } from 'babel-plugin-saykit/next';
11
+ * export default withSayKit({});
12
+ * ```
13
+ *
14
+ * Pair it with `catalogues: 'module'` on the Babel plugin — the plugin has to
15
+ * leave the import alone for the loader to ever be asked for the module.
16
+ *
17
+ * Rules are derived from the SayKit config, so both bundlers get one per bucket
18
+ * targeting exactly that bucket's `output` — nothing else sharing the extension
19
+ * goes through the loader, which matters most for a `.json` bucket, where the
20
+ * alternative would be routing every JSON import in the app through it.
21
+ */
22
+ function withSayKit(nextConfig = {}) {
23
+ const config = (0, _saykit_config_features_loader.resolveConfig)();
24
+ const rules = Object.fromEntries(config.buckets.map((bucket) => [`**/${require_catalogue.catalogueGlob(bucket)}`, {
25
+ loaders: [loader],
26
+ as: "*.js"
27
+ }]));
28
+ return {
29
+ ...nextConfig,
30
+ turbopack: {
31
+ ...nextConfig.turbopack,
32
+ rules: {
33
+ ...nextConfig.turbopack?.rules,
34
+ ...rules
35
+ }
36
+ },
37
+ webpack: (webpackConfig, context) => {
38
+ const result = nextConfig.webpack?.(webpackConfig, context) ?? webpackConfig;
39
+ result.module ??= {};
40
+ result.module.rules ??= [];
41
+ result.module.rules.push({
42
+ test: (path) => require_catalogue.isCatalogue(config, path),
43
+ use: loader,
44
+ type: "javascript/auto"
45
+ });
46
+ return result;
47
+ }
48
+ };
49
+ }
50
+ //#endregion
51
+ exports.withSayKit = withSayKit;
@@ -0,0 +1,38 @@
1
+ //#region src/next/index.d.ts
2
+ /**
3
+ * The slice of Next's config this touches. Typed here rather than imported so
4
+ * the package does not depend on `next`.
5
+ */
6
+ interface NextConfig {
7
+ turbopack?: {
8
+ rules?: Record<string, unknown>;
9
+ [key: string]: unknown;
10
+ };
11
+ webpack?: (config: WebpackConfig, context: unknown) => WebpackConfig;
12
+ [key: string]: unknown;
13
+ }
14
+ interface WebpackConfig {
15
+ module?: {
16
+ rules?: unknown[];
17
+ };
18
+ [key: string]: unknown;
19
+ }
20
+ /**
21
+ * Wrap a Next config so SayKit catalogues load as real modules.
22
+ *
23
+ * ```js
24
+ * import { withSayKit } from 'babel-plugin-saykit/next';
25
+ * export default withSayKit({});
26
+ * ```
27
+ *
28
+ * Pair it with `catalogues: 'module'` on the Babel plugin — the plugin has to
29
+ * leave the import alone for the loader to ever be asked for the module.
30
+ *
31
+ * Rules are derived from the SayKit config, so both bundlers get one per bucket
32
+ * targeting exactly that bucket's `output` — nothing else sharing the extension
33
+ * goes through the loader, which matters most for a `.json` bucket, where the
34
+ * alternative would be routing every JSON import in the app through it.
35
+ */
36
+ declare function withSayKit<T extends NextConfig>(nextConfig?: T): T & Required<Pick<NextConfig, 'turbopack' | 'webpack'>>;
37
+ //#endregion
38
+ export { withSayKit };
@@ -0,0 +1,25 @@
1
+ const require_catalogue = require("../catalogue-B3HA3aVz.cjs");
2
+ //#region src/next/loader.ts
3
+ const config = (0, require("@saykit/config/features/loader").resolveConfig)();
4
+ /**
5
+ * Replaces a catalogue file with its assembled record.
6
+ *
7
+ * This is a *loader* rather than a plugin because Turbopack runs loaders and
8
+ * not webpack plugins, and it is published only so `withSayKit` can name it in
9
+ * the rules it generates. A plain webpack, Vite or Rollup build should reach for
10
+ * `unplugin-saykit` instead, which does the same job through each bundler's own
11
+ * plugin API.
12
+ *
13
+ * The catalogue stays a real module, which is the whole point: the importer
14
+ * keeps a dependency edge to it, so editing a catalogue invalidates exactly the
15
+ * modules that read it. Inlining the record into the importer instead leaves the
16
+ * importer's own bytes unchanged, and no bundler can invalidate on that.
17
+ */
18
+ function saykitLoader(source) {
19
+ const catalogue = require_catalogue.loadCatalogue(config, this.resourcePath);
20
+ if (!catalogue) return source;
21
+ for (const file of catalogue.sources) this.addDependency(file);
22
+ return `export default ${JSON.stringify(catalogue.record)}`;
23
+ }
24
+ //#endregion
25
+ module.exports = saykitLoader;
@@ -0,0 +1,26 @@
1
+ //#region src/next/loader.d.ts
2
+ /**
3
+ * The slice of webpack's loader context this needs. Typing it here rather than
4
+ * depending on `webpack` keeps the package free of a bundler dependency, and
5
+ * Turbopack implements the same surface for the loaders it runs.
6
+ */
7
+ interface LoaderContext {
8
+ resourcePath: string;
9
+ addDependency(file: string): void;
10
+ }
11
+ /**
12
+ * Replaces a catalogue file with its assembled record.
13
+ *
14
+ * This is a *loader* rather than a plugin because Turbopack runs loaders and
15
+ * not webpack plugins, and it is published only so `withSayKit` can name it in
16
+ * the rules it generates. A plain webpack, Vite or Rollup build should reach for
17
+ * `unplugin-saykit` instead, which does the same job through each bundler's own
18
+ * plugin API.
19
+ *
20
+ * The catalogue stays a real module, which is the whole point: the importer
21
+ * keeps a dependency edge to it, so editing a catalogue invalidates exactly the
22
+ * modules that read it. Inlining the record into the importer instead leaves the
23
+ * importer's own bytes unchanged, and no bundler can invalidate on that.
24
+ */
25
+ declare function saykitLoader(this: LoaderContext, source: string): string;
26
+ export = saykitLoader;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babel-plugin-saykit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Babel plugin for SayKit",
5
5
  "keywords": [
6
6
  "i18n",
@@ -27,8 +27,20 @@
27
27
  "type": "module",
28
28
  "exports": {
29
29
  ".": {
30
- "types": "./dist/index.d.cjs",
30
+ "types": "./dist/index.d.cts",
31
31
  "default": "./dist/index.cjs"
32
+ },
33
+ "./metro": {
34
+ "types": "./dist/metro/index.d.cts",
35
+ "default": "./dist/metro/index.cjs"
36
+ },
37
+ "./next": {
38
+ "types": "./dist/next/index.d.cts",
39
+ "default": "./dist/next/index.cjs"
40
+ },
41
+ "./next/loader": {
42
+ "types": "./dist/next/loader.d.cts",
43
+ "default": "./dist/next/loader.cjs"
32
44
  }
33
45
  },
34
46
  "publishConfig": {
@@ -37,7 +49,7 @@
37
49
  },
38
50
  "dependencies": {
39
51
  "@babel/core": "^7.29.7",
40
- "@saykit/config": "^0.6.0"
52
+ "@saykit/config": "^0.7.0"
41
53
  },
42
54
  "devDependencies": {
43
55
  "@types/babel__core": "^7.20.5"