@wuchale/astro 0.2.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.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # `@wuchale/astro`
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@wuchale/astro?logo=npm&logoColor=red&color=blue)](https://www.npmjs.com/package/@wuchale/astro) ![License](https://img.shields.io/github/license/wuchalejs/wuchale)
4
+
5
+ An adapter to integrate `wuchale` in Astro projects.
6
+
7
+ **`wuchale`** is a compile-time internationalization (i18n) toolkit that
8
+ requires zero code changes. Write your components naturally, and `wuchale`
9
+ automatically extracts and replaces translatable strings at build time.
10
+
11
+ - Main documentation: [wuchale.dev](https://wuchale.dev)
12
+ - Setup instructions: [Getting started](https://wuchale.dev/intro/start/)
13
+ - Adapter docs: [Astro](https://wuchale.dev/adapters/astro)
14
+ - Repository: [wuchalejs/wuchale](https://github.com/wuchalejs/wuchale)
@@ -0,0 +1,28 @@
1
+ import type { HeuristicFunc, Adapter, AdapterArgs, LoaderChoice, CreateHeuristicOpts } from "wuchale";
2
+ /**
3
+ * Create a heuristic function optimized for Astro files
4
+ * Uses the default heuristic which handles translatable vs non-translatable strings
5
+ */
6
+ export declare function createAstroHeuristic(opts: CreateHeuristicOpts): HeuristicFunc;
7
+ /** Default Svelte heuristic which extracts top level variable assignments as well, leading to `$derived` being auto added when needed */
8
+ export declare const astroDefaultHeuristic: HeuristicFunc;
9
+ type LoadersAvailable = 'default';
10
+ export type AstroArgs = AdapterArgs<LoadersAvailable>;
11
+ export declare function getDefaultLoaderPath(loader: LoaderChoice<LoadersAvailable>, bundle: boolean): string | null;
12
+ /**
13
+ * Create an Astro adapter for wuchale
14
+ *
15
+ * @example
16
+ * ```js
17
+ * // wuchale.config.js
18
+ * import { adapter as astro } from '@wuchale/astro'
19
+ *
20
+ * export default defineConfig({
21
+ * adapters: {
22
+ * astro: astro({ files: 'src/pages/**\/*.astro' })
23
+ * }
24
+ * })
25
+ * ```
26
+ */
27
+ export declare const adapter: (args?: Partial<AstroArgs>) => Adapter;
28
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,90 @@
1
+ import { defaultGenerateLoadID, deepMergeObjects, createHeuristic, defaultHeuristicOpts, } from "wuchale";
2
+ import { pluralPattern } from "wuchale/adapter-vanilla";
3
+ import { AstroTransformer, } from "./transformer.js";
4
+ import { loaderPathResolver } from "wuchale/adapter-utils";
5
+ /**
6
+ * Create a heuristic function optimized for Astro files
7
+ * Uses the default heuristic which handles translatable vs non-translatable strings
8
+ */
9
+ export function createAstroHeuristic(opts) {
10
+ const defaultHeuristic = createHeuristic(opts);
11
+ return msg => {
12
+ const defRes = defaultHeuristic(msg);
13
+ if (!defRes) {
14
+ return false;
15
+ }
16
+ if (msg.details.scope !== 'script') {
17
+ return defRes;
18
+ }
19
+ if (msg.details.call?.startsWith('Astro.')) {
20
+ return false;
21
+ }
22
+ return defRes;
23
+ };
24
+ }
25
+ /** Default Svelte heuristic which extracts top level variable assignments as well, leading to `$derived` being auto added when needed */
26
+ export const astroDefaultHeuristic = createAstroHeuristic(defaultHeuristicOpts);
27
+ const defaultRuntime = {
28
+ // Astro is SSR-only, so we use non-reactive runtime by default
29
+ initReactive: ({ funcName }) => funcName == null ? false : null, // Only init in top-level functions
30
+ // Astro is SSR - always use non-reactive
31
+ useReactive: () => false,
32
+ reactive: {
33
+ wrapInit: (expr) => expr,
34
+ wrapUse: (expr) => expr,
35
+ },
36
+ plain: {
37
+ wrapInit: (expr) => expr,
38
+ wrapUse: (expr) => expr,
39
+ },
40
+ };
41
+ const defaultArgs = {
42
+ files: 'src/**/*.astro',
43
+ localesDir: './src/locales',
44
+ patterns: [pluralPattern],
45
+ heuristic: astroDefaultHeuristic,
46
+ granularLoad: false,
47
+ bundleLoad: false,
48
+ loader: 'default',
49
+ generateLoadID: defaultGenerateLoadID,
50
+ runtime: defaultRuntime,
51
+ };
52
+ const resolveLoaderPath = loaderPathResolver(import.meta.url, '../src/loaders', 'js');
53
+ export function getDefaultLoaderPath(loader, bundle) {
54
+ if (loader === 'custom') {
55
+ return null;
56
+ }
57
+ // just 'default', so
58
+ let loaderName = 'astro';
59
+ if (bundle) {
60
+ loaderName += '.bundle';
61
+ }
62
+ return resolveLoaderPath(loaderName);
63
+ }
64
+ /**
65
+ * Create an Astro adapter for wuchale
66
+ *
67
+ * @example
68
+ * ```js
69
+ * // wuchale.config.js
70
+ * import { adapter as astro } from '@wuchale/astro'
71
+ *
72
+ * export default defineConfig({
73
+ * adapters: {
74
+ * astro: astro({ files: 'src/pages/**\/*.astro' })
75
+ * }
76
+ * })
77
+ * ```
78
+ */
79
+ export const adapter = (args = {}) => {
80
+ const { heuristic, patterns, runtime, loader, ...rest } = deepMergeObjects(args, defaultArgs);
81
+ return {
82
+ transform: async ({ content, filename, index, expr, matchUrl }) => {
83
+ return new AstroTransformer(content, filename, index, heuristic, patterns, expr, runtime, matchUrl).transformAs();
84
+ },
85
+ loaderExts: ['.js', '.ts'],
86
+ defaultLoaderPath: getDefaultLoaderPath(loader, rest.bundleLoad),
87
+ runtime,
88
+ ...rest,
89
+ };
90
+ };
@@ -0,0 +1,9 @@
1
+ import type { Composite } from 'wuchale';
2
+ export type WuchaleComponentProps = {
3
+ n?: boolean;
4
+ x: Composite;
5
+ t: Function[];
6
+ a?: any[];
7
+ };
8
+ declare const _default: ({ t, n, x, a }: WuchaleComponentProps) => any[];
9
+ export default _default;
@@ -0,0 +1,18 @@
1
+ export default ({ t, n, x, a }) => x.map((x, i) => {
2
+ if (typeof x === 'string') {
3
+ return x;
4
+ }
5
+ if (typeof x === 'number') {
6
+ if (!n || i > 0) {
7
+ return a[x];
8
+ }
9
+ return;
10
+ }
11
+ const tag = t[x[0]];
12
+ if (tag == null) {
13
+ return 'i18n-404:tag';
14
+ }
15
+ else {
16
+ return tag(x);
17
+ }
18
+ });
@@ -0,0 +1,40 @@
1
+ import { Message } from "wuchale";
2
+ import type * as Estree from "acorn";
3
+ import { Transformer } from "wuchale/adapter-vanilla";
4
+ import type { IndexTracker, HeuristicFunc, TransformOutput, RuntimeConf, CatalogExpr, CodePattern, UrlMatcher } from "wuchale";
5
+ import { MixedVisitor, type CommentDirectives } from "wuchale/adapter-utils";
6
+ import type { ElementNode, TextNode, FragmentNode, Node, RootNode, ExpressionNode, AttributeNode, FrontmatterNode, ComponentNode, CustomElementNode } from "@astrojs/compiler/types";
7
+ type MixedAstroNodes = Node;
8
+ export declare class AstroTransformer extends Transformer {
9
+ currentElement?: string;
10
+ inCompoundText: boolean;
11
+ commentDirectivesStack: CommentDirectives[];
12
+ lastVisitIsComment: boolean;
13
+ frontMatterStart?: number;
14
+ mixedVisitor: MixedVisitor<MixedAstroNodes>;
15
+ correctedExprRanges: WeakMap<Node, {
16
+ start: number;
17
+ end: number;
18
+ }>;
19
+ constructor(content: string, filename: string, index: IndexTracker, heuristic: HeuristicFunc, patterns: CodePattern[], catalogExpr: CatalogExpr, rtConf: RuntimeConf, matchUrl: UrlMatcher);
20
+ _saveCorrectedExprRanges: (nodes: Node[], containerEnd: number) => void;
21
+ getRange: (node: Node | AttributeNode) => {
22
+ start: number;
23
+ end: number;
24
+ };
25
+ initMixedVisitor: () => MixedVisitor<Node>;
26
+ _parseAndVisitExpr: (expr: string, startOffset: number, startFromProgram?: boolean) => Message[];
27
+ visitexpression: (node: ExpressionNode) => Message[];
28
+ _visitChildren: (nodes: Node[]) => Message[];
29
+ visitFragmentNode: (node: FragmentNode) => Message[];
30
+ visitelement: (node: ElementNode) => Message[];
31
+ visitcomponent: (node: ComponentNode) => Message[];
32
+ ['visitcustom-element']: (node: CustomElementNode) => Message[];
33
+ visitattribute: (node: AttributeNode) => Message[];
34
+ visittext: (node: TextNode) => Message[];
35
+ visitfrontmatter: (node: FrontmatterNode) => Message[];
36
+ visitroot: (node: RootNode) => Message[];
37
+ visitAs: (node: Node | AttributeNode | Estree.AnyNode) => Message[];
38
+ transformAs: () => Promise<TransformOutput>;
39
+ }
40
+ export {};
@@ -0,0 +1,269 @@
1
+ import MagicString from "magic-string";
2
+ import { Message } from "wuchale";
3
+ import { parseScript, Transformer } from "wuchale/adapter-vanilla";
4
+ import { nonWhitespaceText, MixedVisitor, processCommentDirectives, } from "wuchale/adapter-utils";
5
+ import { parse } from "@astrojs/compiler";
6
+ // Astro nodes that can have children
7
+ const nodesWithChildren = [
8
+ "element",
9
+ "component",
10
+ "custom-element",
11
+ "fragment",
12
+ ];
13
+ const rtRenderFunc = "_w_Tx_";
14
+ export class AstroTransformer extends Transformer {
15
+ // state
16
+ currentElement;
17
+ inCompoundText = false;
18
+ commentDirectivesStack = [];
19
+ lastVisitIsComment = false;
20
+ frontMatterStart;
21
+ mixedVisitor;
22
+ // astro's compiler gives wrong offsets for expressions
23
+ correctedExprRanges = new WeakMap();
24
+ constructor(content, filename, index, heuristic, patterns, catalogExpr, rtConf, matchUrl) {
25
+ // trim() is VERY important, without it offset positions become wrong due to astro's parser
26
+ super(content.trim(), filename, index, heuristic, patterns, catalogExpr, rtConf, matchUrl);
27
+ this.heuristciDetails.insideProgram = false;
28
+ }
29
+ _saveCorrectedExprRanges = (nodes, containerEnd) => {
30
+ for (const [i, child] of nodes.entries()) {
31
+ if (child.type !== 'expression') {
32
+ continue;
33
+ }
34
+ const nextChild = nodes[i + 1];
35
+ let actualEnd;
36
+ if (nextChild != null) {
37
+ actualEnd = nextChild.position?.start?.offset ?? 0;
38
+ if (nextChild.type === 'expression') {
39
+ actualEnd = this.content.indexOf('{', actualEnd);
40
+ }
41
+ }
42
+ else {
43
+ actualEnd = this.content.lastIndexOf('}', containerEnd) + 1;
44
+ }
45
+ this.correctedExprRanges.set(child, {
46
+ start: this.content.indexOf('{', child.position?.start?.offset ?? 0),
47
+ end: actualEnd
48
+ });
49
+ }
50
+ };
51
+ getRange = (node) => {
52
+ if (node.type === 'expression') {
53
+ return this.correctedExprRanges.get(node) ?? { start: -1, end: -1 };
54
+ }
55
+ let { start, end } = node.position ?? {};
56
+ return {
57
+ start: start?.offset ?? -1,
58
+ end: end?.offset ?? -1,
59
+ };
60
+ };
61
+ initMixedVisitor = () => new MixedVisitor({
62
+ mstr: this.mstr,
63
+ vars: this.vars,
64
+ getRange: this.getRange,
65
+ isText: node => node.type === 'text',
66
+ isComment: node => node.type === 'comment',
67
+ leaveInPlace: node => [''].includes(node.type),
68
+ isExpression: node => node.type === 'expression',
69
+ getTextContent: (node) => node.value,
70
+ getCommentData: (node) => node.value,
71
+ canHaveChildren: (node) => nodesWithChildren.includes(node.type),
72
+ visitFunc: (child, inCompoundText) => {
73
+ const inCompoundTextPrev = this.inCompoundText;
74
+ this.inCompoundText = inCompoundText;
75
+ const childTxts = this.visitAs(child);
76
+ this.inCompoundText = inCompoundTextPrev; // restore
77
+ return childTxts;
78
+ },
79
+ visitExpressionTag: this.visitexpression,
80
+ fullHeuristicDetails: this.fullHeuristicDetails,
81
+ checkHeuristic: this.getHeuristicMessageType,
82
+ index: this.index,
83
+ wrapNested: (msgInfo, hasExprs, nestedRanges, lastChildEnd) => {
84
+ let begin = `{${rtRenderFunc}({\nx: `;
85
+ if (this.inCompoundText) {
86
+ begin += `${this.vars().nestCtx},\nn: true`;
87
+ }
88
+ else {
89
+ const index = this.index.get(msgInfo.toKey());
90
+ begin += `${this.vars().rtCtx}(${index})`;
91
+ }
92
+ if (nestedRanges.length > 0) {
93
+ for (const [i, [childStart, _, haveCtx]] of nestedRanges.entries()) {
94
+ let toAppend;
95
+ if (i === 0) {
96
+ toAppend = `${begin},\nt: [`;
97
+ }
98
+ else {
99
+ toAppend = ', ';
100
+ }
101
+ this.mstr.appendRight(childStart, `${toAppend}${haveCtx ? this.vars().nestCtx : '()'} => `);
102
+ }
103
+ begin = `]`;
104
+ }
105
+ let end = '\n})}';
106
+ if (hasExprs) {
107
+ begin += ',\na: [';
108
+ end = ']' + end;
109
+ }
110
+ this.mstr.appendLeft(lastChildEnd, begin);
111
+ this.mstr.appendRight(lastChildEnd, end);
112
+ },
113
+ });
114
+ _parseAndVisitExpr = (expr, startOffset, startFromProgram = false) => {
115
+ const [ast, comments] = parseScript(expr);
116
+ this.comments = comments;
117
+ this.mstr.offset = startOffset;
118
+ // not just visit Program because visitProgram sets insideProgram to true
119
+ let msgs;
120
+ if (startFromProgram) {
121
+ msgs = this.visit(ast);
122
+ }
123
+ else {
124
+ msgs = ast.body.map(this.visit).flat();
125
+ }
126
+ this.mstr.offset = 0; // restore
127
+ return msgs;
128
+ };
129
+ visitexpression = (node) => {
130
+ let expr = '';
131
+ const msgs = [];
132
+ for (const part of node.children) {
133
+ if (part.type === 'text') {
134
+ expr += part.value;
135
+ continue;
136
+ }
137
+ msgs.push(...this.visitAs(part));
138
+ const { start, end } = this.getRange(part);
139
+ expr += `"${' '.repeat(end - start)}"`;
140
+ }
141
+ const { start } = this.getRange(node);
142
+ msgs.push(...this._parseAndVisitExpr(expr, start + 1));
143
+ return msgs;
144
+ };
145
+ _visitChildren = (nodes) => this.mixedVisitor.visit({
146
+ children: nodes,
147
+ commentDirectives: this.commentDirectives,
148
+ inCompoundText: this.inCompoundText,
149
+ scope: 'markup',
150
+ element: this.currentElement,
151
+ useComponent: this.currentElement !== 'title'
152
+ });
153
+ visitFragmentNode = (node) => this._visitChildren(node.children);
154
+ visitelement = (node) => {
155
+ const currentElement = this.currentElement;
156
+ this.currentElement = node.name;
157
+ const msgs = [];
158
+ for (const attrib of node.attributes) {
159
+ msgs.push(...this.visitAs(attrib));
160
+ }
161
+ this._saveCorrectedExprRanges(node.children, node.position?.end?.offset ?? 0);
162
+ msgs.push(...this._visitChildren(node.children));
163
+ this.currentElement = currentElement;
164
+ return msgs;
165
+ };
166
+ visitcomponent = (node) => this.visitelement(node);
167
+ ['visitcustom-element'] = (node) => this.visitelement(node);
168
+ visitattribute = (node) => {
169
+ const heurBase = {
170
+ scope: 'attribute',
171
+ element: this.currentElement,
172
+ attribute: node.name,
173
+ };
174
+ let { start } = this.getRange(node);
175
+ if (node.kind !== 'empty') {
176
+ start = this.content.indexOf('=', start) + 1;
177
+ }
178
+ if (node.kind === 'quoted') {
179
+ const [pass, msgInfo] = this.checkHeuristic(node.value, heurBase);
180
+ if (!pass) {
181
+ return [];
182
+ }
183
+ this.mstr.update(start, start + node.value.length + 2, `{${this.vars().rtTrans}(${this.index.get(msgInfo.toKey())})}`);
184
+ return [msgInfo];
185
+ }
186
+ if (node.kind === 'expression') {
187
+ heurBase.scope = 'script';
188
+ start = this.content.indexOf(node.value, start);
189
+ let expr = node.value;
190
+ if (expr.startsWith('...')) {
191
+ start += 3;
192
+ expr = expr.slice(3);
193
+ }
194
+ return this._parseAndVisitExpr(expr, start);
195
+ }
196
+ return [];
197
+ };
198
+ visittext = (node) => {
199
+ const [startWh, trimmed, endWh] = nonWhitespaceText(node.value);
200
+ const [pass, msgInfo] = this.checkHeuristic(trimmed, {
201
+ scope: 'markup',
202
+ element: this.currentElement,
203
+ });
204
+ if (!pass) {
205
+ return [];
206
+ }
207
+ const { start, end } = this.getRange(node);
208
+ this.mstr.update(start + startWh, end - endWh, `{${this.vars().rtTrans}(${this.index.get(msgInfo.toKey())})}`);
209
+ return [msgInfo];
210
+ };
211
+ visitfrontmatter = (node) => {
212
+ const { start } = this.getRange(node);
213
+ this.frontMatterStart = this.content.indexOf('---', start) + 3;
214
+ return this._parseAndVisitExpr(node.value, this.frontMatterStart, true);
215
+ };
216
+ visitroot = (node) => {
217
+ const msgs = [];
218
+ // ?? [] because it's undefined on an empty file
219
+ for (const rootChild of node.children ?? []) {
220
+ msgs.push(...this.visitAs(rootChild));
221
+ }
222
+ return msgs;
223
+ };
224
+ visitAs = (node) => {
225
+ if (node.type === 'comment') {
226
+ this.commentDirectives = processCommentDirectives(node.value.trim(), this.commentDirectives);
227
+ if (this.lastVisitIsComment) {
228
+ this.commentDirectivesStack[this.commentDirectivesStack.length - 1] = this.commentDirectives;
229
+ }
230
+ else {
231
+ this.commentDirectivesStack.push(this.commentDirectives);
232
+ }
233
+ this.lastVisitIsComment = true;
234
+ return [];
235
+ }
236
+ if (node.type === 'text' && !node.value.trim()) {
237
+ return [];
238
+ }
239
+ let msgs = [];
240
+ const commentDirectivesPrev = this.commentDirectives;
241
+ if (this.lastVisitIsComment) {
242
+ this.commentDirectives = this.commentDirectivesStack.pop();
243
+ this.lastVisitIsComment = false;
244
+ }
245
+ if (this.commentDirectives.ignoreFile) {
246
+ return [];
247
+ }
248
+ if (this.commentDirectives.forceType !== false) {
249
+ msgs = this.visit(node);
250
+ }
251
+ this.commentDirectives = commentDirectivesPrev;
252
+ return msgs;
253
+ };
254
+ transformAs = async () => {
255
+ const { ast } = await parse(this.content);
256
+ this.mstr = new MagicString(this.content);
257
+ this.mixedVisitor = this.initMixedVisitor();
258
+ const msgs = this.visitAs(ast);
259
+ if (this.frontMatterStart == null) {
260
+ this.mstr.appendLeft(0, '---\n');
261
+ this.mstr.appendRight(0, '---\n');
262
+ }
263
+ const header = [
264
+ `import ${rtRenderFunc} from "@wuchale/astro/runtime.js"`,
265
+ this.initRuntime(),
266
+ ].join('\n');
267
+ return this.finalize(msgs, this.frontMatterStart ?? 0, header);
268
+ };
269
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@wuchale/astro",
3
+ "version": "0.2.0",
4
+ "description": "Wuchale i18n adapter for Astro files",
5
+ "scripts": {
6
+ "dev": "tsc --watch",
7
+ "build": "tsc",
8
+ "test": "node tests/index.ts"
9
+ },
10
+ "keywords": [
11
+ "i18n",
12
+ "internationalization",
13
+ "translation",
14
+ "gettext",
15
+ "astro",
16
+ "vite",
17
+ "po",
18
+ "vite-plugin",
19
+ "compile-time",
20
+ "ast",
21
+ "translation-tooling",
22
+ "multilingual",
23
+ "localization",
24
+ "l10n",
25
+ "automatic-i18n"
26
+ ],
27
+ "files": [
28
+ "dist",
29
+ "src/loaders"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./runtime.js": {
37
+ "types": "./dist/runtime.d.ts",
38
+ "default": "./dist/runtime.js"
39
+ }
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/wuchalejs/wuchale.git"
44
+ },
45
+ "homepage": "https://wuchale.dev",
46
+ "bugs": "https://github.com/wuchalejs/wuchale/issues",
47
+ "author": "K1DV5",
48
+ "license": "MIT",
49
+ "dependencies": {
50
+ "@astrojs/compiler": "^2.13.0",
51
+ "@sveltejs/acorn-typescript": "^1.0.8",
52
+ "acorn": "^8.15.0",
53
+ "magic-string": "^0.30.21",
54
+ "wuchale": "^0.19.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/estree-jsx": "^1.0.5",
58
+ "typescript": "^5.9.3"
59
+ },
60
+ "type": "module"
61
+ }
@@ -0,0 +1,21 @@
1
+ // Astro bundle loader template (server-side, synchronous, all locales bundled)
2
+ import { toRuntime } from 'wuchale/runtime'
3
+
4
+ const catalogs = __CATALOGS__
5
+ const locales = Object.keys(catalogs)
6
+
7
+ const store = {}
8
+ for (const locale of locales) {
9
+ store[locale] = toRuntime(catalogs[locale], locale)
10
+ }
11
+
12
+ // Get current locale from global context (set by middleware)
13
+ function getCurrentLocale() {
14
+ return globalThis.__wuchale_locale__ || locales[0]
15
+ }
16
+
17
+ export const getRuntime = (/** @type {string} */ _loadID) => {
18
+ return store[getCurrentLocale()]
19
+ }
20
+
21
+ export const getRuntimeRx = getRuntime
@@ -0,0 +1,14 @@
1
+ // Astro loader template (server-side, synchronous)
2
+ // This is a template file that wuchale will use to generate the actual loader
3
+ import { loadCatalog, loadIDs } from '${PROXY_SYNC}'
4
+ import { currentRuntime } from 'wuchale/load-utils/server'
5
+
6
+ const key = '${KEY}'
7
+
8
+ export { loadCatalog, loadIDs, key }
9
+
10
+ // For non-reactive server-side rendering
11
+ export const getRuntime = (/** @type {string} */ loadID) => currentRuntime(key, loadID)
12
+
13
+ // Same function for compatibility
14
+ export const getRuntimeRx = getRuntime