From baa002ad2f6a7345c2af866b99439e7c73b5d802 Mon Sep 17 00:00:00 2001 From: "M. George Hansen" Date: Fri, 15 Aug 2025 14:12:49 +1200 Subject: [PATCH] Implement a basic astro integration for @websnacksjs/i18n --- packages/i18n-astro/.gitignore | 1 + packages/i18n-astro/LICENSE | 202 ++++++++++++++++++ packages/i18n-astro/package.json | 40 ++++ packages/i18n-astro/src/index.ts | 4 + packages/i18n-astro/src/integration.ts | 135 ++++++++++++ packages/i18n-astro/src/virtual-module.ts | 38 ++++ .../i18n-astro/src/virtual-modules/runtime.ts | 31 +++ packages/i18n-astro/tsconfig.json | 8 + 8 files changed, 459 insertions(+) create mode 100644 packages/i18n-astro/.gitignore create mode 100644 packages/i18n-astro/LICENSE create mode 100644 packages/i18n-astro/package.json create mode 100644 packages/i18n-astro/src/index.ts create mode 100644 packages/i18n-astro/src/integration.ts create mode 100644 packages/i18n-astro/src/virtual-module.ts create mode 100644 packages/i18n-astro/src/virtual-modules/runtime.ts create mode 100644 packages/i18n-astro/tsconfig.json diff --git a/packages/i18n-astro/.gitignore b/packages/i18n-astro/.gitignore new file mode 100644 index 0000000..aa2c544 --- /dev/null +++ b/packages/i18n-astro/.gitignore @@ -0,0 +1 @@ +!/globals.d.ts diff --git a/packages/i18n-astro/LICENSE b/packages/i18n-astro/LICENSE new file mode 100644 index 0000000..68a9d1b --- /dev/null +++ b/packages/i18n-astro/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 M. George Hansen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/i18n-astro/package.json b/packages/i18n-astro/package.json new file mode 100644 index 0000000..9576b7b --- /dev/null +++ b/packages/i18n-astro/package.json @@ -0,0 +1,40 @@ +{ + "name": "@websnacksjs/i18n-astro", + "version": "0.1.0", + "description": "Astro integration for @websnacksjs/i18n", + "keywords": [ + "websnacks", + "i18n", + "translation", + "internationalization", + "i18n", + "astro" + ], + "author": "M. George Hansen ", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist/", + "src/" + ], + "scripts": { + "build": "tsc --build" + }, + "engines": { + "node": ">=22" + }, + "engineStrict": true, + "peerDependencies": { + "astro": "^5" + }, + "dependencies": { + "@websnacksjs/i18n": "0.1.0", + "ts-poet": "^6.12.0" + }, + "devDependencies": { + "vite": "^6" + } +} diff --git a/packages/i18n-astro/src/index.ts b/packages/i18n-astro/src/index.ts new file mode 100644 index 0000000..a219135 --- /dev/null +++ b/packages/i18n-astro/src/index.ts @@ -0,0 +1,4 @@ +export { + default, + type I18nAstroOptions, +} from "./integration.js"; diff --git a/packages/i18n-astro/src/integration.ts b/packages/i18n-astro/src/integration.ts new file mode 100644 index 0000000..bf872ce --- /dev/null +++ b/packages/i18n-astro/src/integration.ts @@ -0,0 +1,135 @@ +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AstroIntegration } from "astro"; +import { type Code, code, literalOf } from "ts-poet"; +import runtimeVirtualModule from "./virtual-modules/runtime.js"; + +export type I18nAstroOptions = { + baseLocale: string; + messagesDir?: URL | string; +}; + +const messagesUrlPrefix = "messages"; + +export default function i18n({ + baseLocale, + // TODO: optimize for when messages are in public folder + messagesDir = "./messages", +}: I18nAstroOptions): AstroIntegration { + let baseLocaleDir: URL; + const namespaces: string[] = []; + let supportedLocales: string[]; + let runtimeModuleId: string; + return { + name: "@websnacksjs/i18n-astro", + hooks: { + async "astro:config:setup"({ config, updateConfig }) { + messagesDir = new URL(`${messagesDir}/`, config.root); + supportedLocales = await fs + .readdir(messagesDir) + .catch((cause) => { + if ( + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + throw new Error( + `localized messages directory ${(messagesDir as URL).pathname} does not exist (did you specify the right path in messagesDir option?)`, + ); + } + + throw new Error( + `failed to read localized messages from directory ${(messagesDir as URL).pathname}: ${cause.message ?? JSON.stringify(cause)}}`, + { cause }, + ); + }); + if (!supportedLocales.includes(baseLocale)) { + throw new Error( + `baseLocale ${JSON.stringify(baseLocale)} does not exist in messagesDir ${JSON.stringify(messagesDir.pathname)}`, + ); + } + + baseLocaleDir = new URL(`./${baseLocale}/`, messagesDir); + for await (const fileName of fs.glob("*.json", { + cwd: baseLocaleDir.pathname, + })) { + const namespace = path.basename(fileName, ".json"); + if (namespace !== "common") { + namespaces.push(namespace); + } + } + + const runtimeModule = runtimeVirtualModule({ + namespaces, + supportedLocales, + messagesDir, + messagesUrlPrefix, + }); + runtimeModuleId = runtimeModule.moduleId; + updateConfig({ + vite: { + plugins: [runtimeModule.plugin], + }, + }); + }, + async "astro:server:setup"({ server }) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith(messagesUrlPrefix)) { + return next(); + } + + const relPath = req.url.slice(messagesUrlPrefix.length); + const filePath = new URL(`./${relPath}`, messagesDir); + createReadStream(filePath) + .once("error", (err) => { + if ( + (err as NodeJS.ErrnoException).code === "ENOENT" + ) { + res.statusCode = 404; + res.end(); + } else { + res.statusCode = 500; + res.end(); + } + }) + .once("open", () => { + res.writeHead(200); + }) + .pipe(res); + }); + }, + async "astro:build:done"({ dir }) { + const messageAssetsDir = new URL(`./${messagesUrlPrefix}`, dir); + await fs.cp(messagesDir, messageAssetsDir); + }, + async "astro:config:done"({ injectTypes }) { + const commonMessagesFile = new URL( + "./common.json", + baseLocaleDir, + ); + injectTypes({ + filename: "types.d.ts", + content: code` + declare module "${runtimeModuleId}" { + import type I18n from "@websnacksjs/i18n"; + const i18n: I18n<{ + common: typeof import(${literalOf(commonMessagesFile.pathname)}); + } & ${namespaces.reduce( + (acc, ns) => { + const file = new URL( + `./${ns}.json`, + baseLocaleDir, + ); + acc[ns] = + code`typeof import(${literalOf(file.pathname)})`; + return acc; + }, + {} as Record, + )}>; + export default i18n; + } + `.toString(), + }); + }, + }, + }; +} diff --git a/packages/i18n-astro/src/virtual-module.ts b/packages/i18n-astro/src/virtual-module.ts new file mode 100644 index 0000000..96d959f --- /dev/null +++ b/packages/i18n-astro/src/virtual-module.ts @@ -0,0 +1,38 @@ +import type { Code } from "ts-poet"; +import type { Plugin } from "vite"; + +export type VirtualModuleOptions = { + moduleId: string; + content: Code; +}; + +export type VirtualModule = { + moduleId: string; + plugin: Plugin; +}; + +export const defineVirtualModule = ({ + moduleId, + content, +}: VirtualModuleOptions): VirtualModule => { + moduleId = `@websnacksjs/i18n-astro:${moduleId}`; + const resolvedModuleId = `\0${moduleId}`; + return { + moduleId, + plugin: { + name: moduleId, + resolveId(id) { + if (id !== moduleId) { + return; + } + return resolvedModuleId; + }, + load(id) { + if (id !== resolvedModuleId) { + return; + } + return content.toString(); + }, + }, + }; +}; diff --git a/packages/i18n-astro/src/virtual-modules/runtime.ts b/packages/i18n-astro/src/virtual-modules/runtime.ts new file mode 100644 index 0000000..50335ac --- /dev/null +++ b/packages/i18n-astro/src/virtual-modules/runtime.ts @@ -0,0 +1,31 @@ +import { arrayOf, code, imp, literalOf } from "ts-poet"; +import { defineVirtualModule, type VirtualModule } from "../virtual-module.js"; + +export type ClientVirtualModuleOptions = { + supportedLocales: string[]; + namespaces: string[]; + messagesUrlPrefix: string; + messagesDir: URL; +}; + +export default function runtimeVirtualModule({ + supportedLocales, + namespaces = [], + messagesUrlPrefix, + messagesDir, +}: ClientVirtualModuleOptions): VirtualModule { + const I18n = imp("I18n=@websnacksjs/i18n"); + return defineVirtualModule({ + moduleId: "runtime", + content: code` + const i18n = new ${I18n}({ + supportedLocales: ${arrayOf(...supportedLocales)}, + namespaces: ${arrayOf(...namespaces)}, + messagesUrlTemplate: typeof window === "undefined" + ? new URL("./:locale/:namespace.json", ${literalOf(messagesDir)}) + : new URL(${literalOf(`/${messagesUrlPrefix}/:locale/:namespace.json`)}, window.location.origin), + }); + export default i18n; + `, + }); +} diff --git a/packages/i18n-astro/tsconfig.json b/packages/i18n-astro/tsconfig.json new file mode 100644 index 0000000..1ffbc19 --- /dev/null +++ b/packages/i18n-astro/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": [ + "../../tsconfig.common.json", + "@tsconfig/node-lts/tsconfig.json" + ], + "include": ["src"], + "references": [{ "path": "../i18n" }] +}