Compare commits

...

No commits in common. "main" and "feat/i18n-astro" have entirely different histories.

43 changed files with 5448 additions and 1432 deletions

View file

@ -10,7 +10,3 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yaml,yml}]
indent_style = space
indent_size = 2

View file

@ -1,24 +0,0 @@
name: Continuous Integration (CI)
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24]
name: Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "npm"
cache-dependency-path: "package-lock.json"
- run: npm ci
- run: npm run test --workspaces --if-present

6
.gitignore vendored
View file

@ -35,11 +35,6 @@
!/flake.nix
!/flake.lock
### Repository configuration ###
!/.github/workflows/*.yml
!/.husky/commit-msg
!/conventional.config.js
### Workspace configuration ###
!/package.json
!/package-lock.json
@ -60,7 +55,6 @@
!/packages/*/*.config.ts
### Sources ###
!/packages/*/bin/*.js
!/packages/*/src/**/*.ts
!/packages/*/src/**/*.tsx

View file

@ -1 +0,0 @@
npx conventional commit-msg "$1"

View file

@ -18,8 +18,5 @@
"--workspaces",
"--if-present",
"pretest"
],
"nodejs-testing.debugOptions": {
"autoAttachChildProcesses": true
}
]
}

View file

@ -1,71 +0,0 @@
import * as fs from "node:fs/promises";
import { defineConfig } from "@websnacksjs/conventional";
/**
* @param {import("@websnacksjs/conventional").CommitMessage} message
* @returns {void}
*/
const validateRepoScopedCommit = (message) => {
const supportedTypes = ["docs", "chore"];
if (!supportedTypes.includes(message.type)) {
throw new Error(
`${JSON.stringify(message.type)} is not a supported repo-scoped commit type ` +
`(must be one of ${JSON.stringify(supportedTypes).replaceAll(",", ", ")})`,
);
}
};
const packagePrefix = "@websnacksjs/";
const packages = await fs.readdir(new URL("./packages", import.meta.url));
/**
* @param {import("@websnacksjs/conventional").CommitMessage} message
* @returns {void}
*/
const validatePackageScopedCommit = (message) => {
const pkg = message.scope?.slice(packagePrefix.length) ?? "";
if (!packages.includes(pkg)) {
throw new Error(
`unknown package ${JSON.stringify(pkg)} referenced in commit scope`,
);
}
const supportedTypes = ["feat", "fix", "docs", "test", "chore"];
if (!supportedTypes.includes(message.type)) {
throw new Error(
`${JSON.stringify(message.type)} is not a supported package-scoped commit type ` +
`(must be one of ${JSON.stringify(supportedTypes).replaceAll(",", ", ")})`,
);
}
};
export default defineConfig({
validateCommitMessage(message) {
if (!message.scope) {
throw new Error(
`missing required scope (use "repo" for monorepo-related commits or "@websnacksjs/:package" for package-specific commits)`,
);
}
if (message.footers.length > 0) {
throw new Error(
`commit message footers are currently unsupported ` +
`(try removing them from your commit message)`,
);
}
if (message.scope === "repo") {
validateRepoScopedCommit(message);
return;
}
if (message.scope.startsWith(packagePrefix)) {
validatePackageScopedCommit(message);
return;
}
throw new Error(
`scope ${JSON.stringify(message.scope)} is unsupported (try one of ["repo", "@websnacksjs/:package"])`,
);
},
});

5153
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,11 +3,9 @@
"./packages/*"
],
"private": true,
"type": "module",
"scripts": {
"build": "tsc --build",
"clean": "git clean -dxi --exclude .direnv",
"prepare": "husky"
"clean": "git clean -dxi --exclude .direnv"
},
"devDependencies": {
"@biomejs/biome": "=2.1.3",
@ -15,8 +13,6 @@
"@tsconfig/strictest": "^2.0.5",
"@types/deno": "^2.3.0",
"@types/node": "^24.2.0",
"@websnacksjs/conventional": "^0.1.0",
"husky": "^9.1.7",
"typescript": "^5.9.2"
}
}

View file

@ -1,117 +0,0 @@
# @websnacksjs/conventional
A lightweight, zero-depenency cli tool for enforcing [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) standards on commit messages.
Designed as a simpler and opinionated alternative to [`commitlint`](https://commitlint.js.org/) with a convention-over-configuration philosophy.
## 📦 Installation
```bash
npm install @websnacksjs/conventional
# or
yarn add @websnacksjs/conventional
# or
pnpm add @websnacksjs/conventional
```
## 🚀 Quick Start
[`huksy`](https://typicode.github.io/husky/) is recommended as a lightweight way to automate configuration of git commit hooks in local checkouts of your code:
```bash
npm install --save-dev husky && npx husky init
```
Once `husky` is installed and configured, using `@websnacksjs/conventional` is as simple as adding a new `.husky/commit-msg` file:
**`./.husky/commit-msg`**
```bash
npx conventional commit-msg "$1"
```
From now on, commit messages will be validated to meet the conventional commit v1.0.0 specification by `@websnacksjs/conventional`.
## 📖 Usage Details
### ⚙️ User-defined commit message validations
By default, `@websnacksjs/conventional` will only ensure that commit messages are valid conventional commit message. If you want to add additional validations (such as only allowing certain scopes or commit types), you can add a `conventional.config.js` file to the root of your repository:
**`./conventional.config.js`**
```js
import { defineConfig } from '@websnacksjs/conventional';
const validTypes = ['feat', 'fix', 'chore'];
export default defineConfig({
validateCommitMessage(commitMessage) {
// commitMessage is a parsed version of the conventional commit message
if (!validTypes.includes(commitMessage.scope)) {
throw new Error(`${commitMessage.scope} is not a valid scope`)
}
}
});
```
Now, properly formatted conventional commit messages that aren't one of "feat", "fix", or "chore" will be rejected.
### 🧹 Normalized whitespace & formatting
`@websnacksjs/conventional` will correct poor formatting in commit messages automatically. This includes:
- Removing leading & trailing whitespace in commit message scopes, descriptions, bodies, and footer values;
- Adding correct new lines to separate commit message summary lines from bodies and footers;
- Collapsing whitespace in commit message scopes, descriptions, bodies, and breaking changes and footers;
- Converting the first character to lowercase in descriptions and footer values;
- Adding missing puncuation to commit message descriptions, bodies, and breaking changes;
- Converting the first character to uppercase in commit message bodies and breaking changes;
- Converting "BREAKING-CHANGE" footers to "BREAKING CHANGE" for consistency.
- Adding breaking signifier "!" to commit message summaries when BREAKING CHANGE footers are present.
For example:
```plaintext
feat( subpackage-a ): added lots of new features
adds a lot of neat stuff
you should check it out brah!
BREAKING-CHANGE: it's gonna break production, gurranteed
authored-by: Someone
```
Is normalized to:
```plaintext
feat(subpackage-a)!: added lots of new features
Adds a lot of neat stuff.
You should check it out brah!
BREAKING CHANGE: it's gonna break production, gurranteed
Authored-by: Someone
```
Additionally, it tries to detect common mistakes such as missing full commit descriptions and bodies that have a trailing comma:
```plaintext
feat(subpackage-a): added new feature,
// ^ Rejected: trailing comma indicates potential copy-paste error and partly missing description
```
And rejecting commits with summaries over 80 lines long (which get truncated in most git UIs):
```plaintext
feat(subpackage-a): this adds TONS of new features such as walking your dog, buying groceries, paying your bills, and more!
// ^ Rejected: commit summary is over 80 lines long and would result in truncation
```
## 📜 License
`@websnacksjs/conventional` is licensed under the Apache-2.0 license. See the [LICENSE](/LICENSE) file for details.

View file

@ -1,3 +0,0 @@
#!/usr/bin/env node
import "../dist/bin/main.js";

View file

@ -1,30 +0,0 @@
{
"name": "@websnacksjs/conventional",
"version": "0.1.0",
"description": "A conventional commit cli application",
"keywords": [
"websnacks",
"conventional-commit",
"git"
],
"author": "M. George Hansen <mgeorge@technopolitica.com>",
"license": "Apache-2.0",
"type": "module",
"bin": "bin/conventional.js",
"exports": {
".": "./dist/lib/index.js"
},
"files": [
"dist/",
"src/"
],
"scripts": {
"build": "tsc --build",
"test": "tsc --noEmit --project tests/tsconfig.json && node --experimental-strip-types --disable-warning=ExperimentalWarning --test --test-reporter=dot",
"pretest": "npm run build",
"prepack": "npm run build"
},
"devDependencies": {
"ts-poet": "^6.12.0"
}
}

View file

@ -1,291 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import { findConfig } from "../lib/config.js";
import type { CommitMessage, Footer } from "../lib/types.js";
import { removeElements, uniqueBy } from "../lib/utils/array.js";
import { formatError, normalizeError } from "../lib/utils/error.js";
import {
assertNoPunctuation,
collapseWhitespace,
lowerCaseFirstCharacter,
properlyPuncuate,
removeComments,
removeTrailingPunctuation,
unwrapText,
upperCaseFirstCharacter,
} from "../lib/utils/string.js";
import wrap from "../lib/utils/wrap.js";
const summaryRegex =
/^(?<type>\w+)(?:\((?<scope>[^()]+)\))?(?<breaking>!)?: (?<description>.+)/;
const footerRegex = /^(?<key>[\w-]+|BREAKING CHANGE): (?<value>.+)/;
const normalizeDescription = (description: string): string => {
description = collapseWhitespace(description);
description = lowerCaseFirstCharacter(description);
description = removeTrailingPunctuation(description);
return description;
};
const normalizeType = (type: string): string => {
type = type.toLowerCase();
return type;
};
const normalizeScope = (scope: string): string => {
scope = collapseWhitespace(scope);
scope = scope.toLowerCase();
return scope;
};
const normalizeBody = (body: string): string => {
body = body.trim();
if (body === "") {
return "";
}
// If the user already wrapped their body text, join it back up.
body = unwrapText(body);
body = body
.split("\n")
.map((paragraph) => {
paragraph = collapseWhitespace(paragraph);
paragraph = upperCaseFirstCharacter(paragraph);
paragraph = properlyPuncuate(paragraph);
return paragraph;
})
.join("\n\n");
return body;
};
const BREAKING_CHANGE_KEY = "BREAKING CHANGE";
const normalizeBreakingChange = (breakingChanges: string): string => {
breakingChanges = collapseWhitespace(breakingChanges);
breakingChanges = lowerCaseFirstCharacter(breakingChanges);
breakingChanges = removeTrailingPunctuation(breakingChanges);
assertNoPunctuation(breakingChanges);
return breakingChanges;
};
const normalizeFooter = (footer: Footer): Footer => {
// Copy input param so we don't modify it accidentally.
footer = { ...footer };
let [firstWord, ...trailingWords] = footer.key.split("-") as [
string,
...string[],
];
firstWord = upperCaseFirstCharacter(firstWord);
trailingWords = trailingWords.map(lowerCaseFirstCharacter);
footer.key = [firstWord, ...trailingWords].join("-");
footer.value = footer.value.trim();
footer.value = collapseWhitespace(footer.value);
return footer;
};
const normalizeFooters = (footers: Footer[]): Footer[] => {
footers = uniqueBy(footers, (footer) => `${footer.key}: ${footer.value}`);
footers = footers.map(normalizeFooter);
return footers;
};
const parseCommitMessageBody = (
lines: string[],
): { body: string; breakingChanges: string[]; footers: Footer[] } => {
// Parse the body until we see a line that looks like a footer.
const bodyLines = [];
lines = lines.toReversed();
let line = lines.pop();
while (line !== undefined) {
if (line.match(footerRegex)) {
lines.push(line);
break;
}
bodyLines.push(line);
line = lines.pop();
}
let body = bodyLines.join("\n");
try {
body = normalizeBody(body);
} catch (cause) {
throw new Error(`commit message body malformed`, {
cause: normalizeError(cause),
});
}
// Remaining lines must be footers
let footers: Footer[] = [];
let additionalFooterLines: string[] = [];
const appendLinesToLastFooter = () => {
if (additionalFooterLines.length === 0) {
return;
}
const lastFooter = footers[footers.length - 1];
if (!lastFooter) {
throw new Error(
`found lines that don't look like a footer and don't belong to any other footer: ` +
`${JSON.stringify(additionalFooterLines.join("\n"))}`,
);
}
for (const additionalLine of additionalFooterLines) {
lastFooter.value += `\n${additionalLine}`;
}
additionalFooterLines = [];
};
// Reverse lines since we're no longer using pop() to remove the body lines.
lines = lines.toReversed();
for (const line of lines) {
const match = line.match(footerRegex);
// We may be on a multiline footer, so continue until we see another
// footer declared.
if (!match) {
additionalFooterLines.push(line);
continue;
}
appendLinesToLastFooter();
const { key, value } = match.groups as { key: string; value: string };
footers.push({ key, value });
}
appendLinesToLastFooter();
let breakingChanges = removeElements(
footers,
({ key }) => key === BREAKING_CHANGE_KEY || key === "BREAKING-CHANGE",
).map(({ value }) => value);
breakingChanges = breakingChanges.map(normalizeBreakingChange);
footers = normalizeFooters(footers);
return {
body,
breakingChanges,
footers,
};
};
const parseCommitMessage = (message: string): CommitMessage => {
message = removeComments(message);
const [firstLine, ...lines] = message.split(/\r?\n/) as [
string,
...string[],
];
const summaryMatch = firstLine.match(summaryRegex);
if (!summaryMatch) {
throw new Error(
"commit message is not in a conventional commit v1.0.0 compliant format (see https://www.conventionalcommits.org/en/v1.0.0/)",
);
}
let {
type,
scope = "",
breaking = "",
description,
} = summaryMatch.groups as {
type: string;
scope: string | undefined;
breaking: string | undefined;
description: string;
};
type = normalizeType(type);
scope = normalizeScope(scope);
try {
description = normalizeDescription(description);
} catch (cause) {
throw new Error(`commit message description malformed`, {
cause: normalizeError(cause),
});
}
const { body, breakingChanges, footers } = parseCommitMessageBody(lines);
const isBreaking = breaking !== "" || breakingChanges.length > 0;
return {
type,
scope,
isBreaking,
description,
body,
breakingChanges,
footers,
};
};
const MAX_LINE_LENGTH = 72;
const serializeCommitMessage = ({
type,
scope,
isBreaking,
description,
body,
breakingChanges,
footers,
}: CommitMessage): string => {
const summaryLine = `${type}${scope ? `(${scope})` : ""}${isBreaking ? "!" : ""}: ${description}`;
if (summaryLine.length > MAX_LINE_LENGTH) {
throw new Error(
`commit message summary line exceeds max length of ${MAX_LINE_LENGTH} characters`,
);
}
let result = summaryLine;
if (body) {
result += `\n\n${wrap(body, { width: MAX_LINE_LENGTH })}`;
}
const breakingChangeLines = breakingChanges.map(
(breakingChange) => `${BREAKING_CHANGE_KEY}: ${breakingChange}`,
);
if (breakingChangeLines.length > 0) {
const breakingChanges = breakingChangeLines.join("\n");
result += `\n\n${breakingChanges}`;
}
const footerLines = footers.map(({ key, value }) => `${key}: ${value}`);
if (footerLines.length > 0) {
result += `\n\n${footerLines.join("\n")}`;
}
return result;
};
const main = async (): Promise<void> => {
const messageFile = process.argv[2];
assert.ok(messageFile, "expected message file as first parameter");
const config = await findConfig();
const userCommitMessage = await fs.readFile(messageFile, "utf-8");
const commitMessage = parseCommitMessage(userCommitMessage);
await (async () => config.validateCommitMessage?.(commitMessage))().catch(
(cause) => {
throw new Error(
`user-defined validateCommitMessage(...) rejected commit message`,
{ cause: normalizeError(cause) },
);
},
);
// Write back the commit message to normalize spacing and newlines.
await fs.writeFile(messageFile, serializeCommitMessage(commitMessage));
};
await main()
.catch(normalizeError)
.then((error) => {
if (error) {
console.error(`⛔ Error: ${formatError(error)}`);
process.exit(1);
}
});

View file

@ -1,10 +0,0 @@
const [command = ""] = process.argv.splice(2, 1);
switch (command) {
case "commit-msg": {
await import("./commit-msg.js");
break;
}
default: {
console.error(`⛔ unknown command ${command}`);
}
}

View file

@ -1,102 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { CommitMessage } from "./types.js";
import { normalizeError } from "./utils/error.js";
export type ConventionalConfig = {
validateCommitMessage?(message: CommitMessage): void | Promise<void>;
};
export type ConventionalUserConfig = Partial<ConventionalConfig>;
export const defineConfig = (
config: ConventionalUserConfig,
): ConventionalUserConfig => config;
function validateConfig(
value: unknown,
): asserts value is ConventionalUserConfig {
if (typeof value !== "object") {
throw new Error(`not an object`);
}
if (value === null) {
throw new Error(`is null`);
}
if (
"validateCommitMessage" in value &&
typeof value.validateCommitMessage !== "function"
) {
throw new Error(`validateCommitMessage is not a function`);
}
}
const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => {
if (!(value instanceof Error)) {
return false;
}
if ("errno" in value && typeof value.errno !== "number") {
return false;
}
if ("code" in value && typeof value.code !== "string") {
return false;
}
if ("path" in value && typeof value.path !== "string") {
return false;
}
if ("syscall" in value && typeof value.syscall !== "string") {
return false;
}
return true;
};
const DEFAULT_CONFIG: ConventionalUserConfig = {};
export const findConfig = async (): Promise<ConventionalConfig> => {
const configDir = new URL(`file://${process.cwd()}/`);
do {
const configFile = new URL("./conventional.config.js", configDir);
try {
await fs.stat(configFile);
} catch (error) {
if (!isErrnoException(error) || error.code !== "ENOENT") {
throw error;
}
configDir.pathname = `${configDir.pathname.split("/").slice(0, -2).join("/")}/`;
continue;
}
const config = await import(configFile.toString())
.then((mod: object) => {
if (!("default" in mod)) {
throw new Error(
`no default export in ${fileURLToPath(configFile)}`,
);
}
return mod.default;
})
.catch((cause) => {
const relativeConfigFile = path.relative(
process.cwd(),
fileURLToPath(configFile),
);
throw new Error(
`failed to load config from ./${relativeConfigFile}`,
{
cause: normalizeError(cause),
},
);
});
validateConfig(config);
return config;
} while (configDir.pathname !== "/");
return DEFAULT_CONFIG;
};

View file

@ -1,3 +0,0 @@
export type * from "./config.js";
export { defineConfig } from "./config.js";
export type * from "./types.js";

View file

@ -1,14 +0,0 @@
export type Footer = {
key: string;
value: string;
};
export type CommitMessage = {
type: string;
scope?: string | undefined;
isBreaking: boolean;
description: string;
body?: string | undefined;
breakingChanges: string[];
footers: Footer[];
};

View file

@ -1,30 +0,0 @@
export const uniqueBy = <T>(items: T[], keyFn: (item: T) => string): T[] => {
const seen = new Set();
items = items.filter((x) => {
const key = keyFn(x);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return items;
};
export const removeElements = <T>(
elements: T[],
pred: (elem: T) => boolean,
): T[] => {
const removedElements: T[] = [];
let newLength = 0;
for (const elem of elements) {
if (pred(elem)) {
removedElements.push(elem);
continue;
}
elements[newLength++] = elem;
}
elements.length = newLength;
return removedElements;
};

View file

@ -1,28 +0,0 @@
export const normalizeError = (error: unknown): Error => {
if (!(error instanceof Error)) {
error = new Error(`non-Error type thrown: ${JSON.stringify(error)}`);
}
return error as Error;
};
const errorCauseChain = (error: Error): Error[] => {
const causeChain: Error[] = [];
let cause = error.cause;
while (cause !== undefined) {
causeChain.push(normalizeError(cause));
if (cause instanceof Error) {
cause = cause.cause;
}
}
return causeChain;
};
export const formatError = (error: Error): string => {
let message = error.message;
const causeChain = errorCauseChain(error);
for (const cause of causeChain) {
message += `: ${cause.message}`;
}
return message;
};

View file

@ -1,73 +0,0 @@
export const collapseWhitespace = (str: string): string => {
str = str.trim();
// Convert all tabs to spaces.
str = str.replaceAll(/\t/g, " ");
str = str.replaceAll(/ +/g, " ");
return str;
};
export const lowerCaseFirstCharacter = (str: string): string => {
const firstChar = str[0];
if (!firstChar) {
// str is empty
return "";
}
return `${firstChar.toLowerCase()}${str.slice(1)}`;
};
export const upperCaseFirstCharacter = (str: string): string => {
const firstChar = str[0];
if (!firstChar) {
// str is empty
return "";
}
return `${firstChar.toUpperCase()}${str.slice(1)}`;
};
export const assertNoTrailingComma = (str: string): void => {
// Trailing commas could indicate that the user is missing part of the
// string (e.g. copy-and-paste error).
if (str.endsWith(",")) {
throw new Error(
`ends w/ trailing comma "," (did you include the entire string?)`,
);
}
};
export const assertNoPunctuation = (str: string): void => {
if (str.match(/[.!?]|\.\.\./)) {
throw new Error(`contains punctuation`);
}
};
export const removeTrailingPunctuation = (str: string): string => {
assertNoTrailingComma(str);
str = str.replace(/\W+$/, "");
return str;
};
export const properlyPuncuate = (str: string): string => {
assertNoTrailingComma(str);
if (!str.match(/[.!?]|\.\.\.$/)) {
str = `${str}.`;
}
return str;
};
export const unwrapText = (str: string): string => {
const paragraphs = str.split("\n\n");
return paragraphs
.map((paragraph) => paragraph.split("\n").join(" "))
.join("\n");
};
export const removeComments = (str: string): string => {
return str
.split("\n")
.filter((line) => !line.startsWith("#"))
.join("\n");
};

View file

@ -1,50 +0,0 @@
/*!
* word-wrap <https://github.com/jonschlinkert/word-wrap>
*
* Copyright (c) 2014-2023, Jon Schlinkert.
* Released under the MIT License.
*/
const trimEnd = (str: string): string => {
let lastCharPos = str.length - 1;
let lastChar = str[lastCharPos];
while (lastChar === " " || lastChar === "\t") {
lastChar = str[--lastCharPos];
}
return str.substring(0, lastCharPos + 1);
};
const trimTabAndSpaces = (str: string): string => {
const lines = str.split("\n");
const trimmedLines = lines.map((line) => trimEnd(line));
return trimmedLines.join("\n");
};
export interface WrapOptions {
/**
* The width of the text before wrapping to a new line.
*/
width: number;
}
export default function wrap(str: string, { width }: WrapOptions): string {
if (str === "") {
return str;
}
let regexString = `.{1,${width}}`;
regexString += "([\\s\u200B]+|$)|[^\\s\u200B]+?([\\s\u200B]+|$)";
const re = new RegExp(regexString, "g");
const lines = str.match(re) || [];
let result = lines
.map((line) => {
if (line.slice(-1) === "\n") {
line = line.slice(0, line.length - 1);
}
return line;
})
.join("\n");
result = trimTabAndSpaces(result);
return result;
}

View file

@ -1,469 +0,0 @@
import assert from "node:assert/strict";
import { fork } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import inspector from "node:inspector";
import os from "node:os";
import path from "node:path";
import {
after,
afterEach,
before,
beforeEach,
describe,
it,
mock,
} from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { code } from "ts-poet";
const detectInspectorPort = (): string | undefined => {
let inspectorUrl: URL | string | undefined = inspector.url();
if (!inspectorUrl) {
return;
}
inspectorUrl = new URL(inspectorUrl);
return inspectorUrl.port;
};
const runCommand = async (
messageFile: URL,
{
signal,
}: {
signal: AbortSignal;
},
): Promise<{ stderr: string; stdout: string; code: number }> => {
const binFile = new URL("../../bin/conventional.js", import.meta.url);
const inspectorPort = detectInspectorPort();
const command = fork(binFile, ["commit-msg", fileURLToPath(messageFile)], {
signal,
stdio: ["ipc", "pipe", "pipe"],
execArgv:
inspectorPort != null
? [
"--inspect",
"--inspect-port=0",
// Ensure that inspector output isn't redirectd to
// stderr which can mess up our tests that assert on
// stderr.
"--inspect-publish-uid=http",
]
: [],
});
let stderr = "";
command.stderr?.setEncoding("utf-8");
command.stderr?.on("data", (chunk) => {
stderr += chunk;
});
let stdout = "";
command.stdout?.setEncoding("utf-8");
command.stdout?.on("data", (chunk) => {
stdout += chunk;
});
const code = await new Promise<number>((resolve) => {
command.once("close", (code) => resolve(code ?? 0));
});
stderr = stderr.trim();
stdout = stdout.trim();
return {
stderr,
stdout,
code,
};
};
const previousCwd = process.cwd();
let tempDir: URL;
before(async () => {
tempDir = await fs
.mkdtemp(path.join(os.tmpdir(), "websnacks-conventional-"))
.then(pathToFileURL);
// Add trailing slash so relative URLs get properly appended.
tempDir.pathname = `${tempDir.pathname}/`;
process.chdir(fileURLToPath(tempDir));
});
after(async () => {
process.chdir(previousCwd);
await fs.rm(tempDir, { recursive: true, force: true });
});
const mockCommitMessage = async (message: string): Promise<URL> => {
const messageFile = new URL(`./message-${randomUUID()}`, tempDir);
await fs.writeFile(messageFile, message, "utf-8");
return messageFile;
};
afterEach(() => {
mock.reset();
});
describe("conventional commit-msg ...", () => {
describe("when commit message is missing conventional commit summary line", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage("fixes an issue");
});
it("produces an appropriate error", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
"⛔ Error: commit message is not in a conventional commit v1.0.0 compliant format",
"(see https://www.conventionalcommits.org/en/v1.0.0/)",
].join(" "),
},
);
});
});
describe("when commit message contains only a properly formatted summary line & whitespace", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
["fix(something): fixes something, I think", "", ""].join("\n"),
);
});
it("outputs properly formatted commit message", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
"fix(something): fixes something, I think",
);
});
});
describe('when commit message description ends with a comma ","', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
"fix: resolves issue where things wouldn't work,",
);
});
it("rejects the commit message w/ an appropriate error", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
"⛔ Error: commit message description malformed:",
'ends w/ trailing comma "," (did you include the entire string?)',
].join(" "),
},
);
});
});
describe('when commit message body ends with a comma ","', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"fix: resolves issue where things wouldn't work",
"",
"This fixes that annoying bug reported last week,",
].join("\n"),
);
});
it("rejects the commit message w/ an appropriate error", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: '⛔ Error: commit message body malformed: ends w/ trailing comma "," (did you include the entire string?)',
},
);
});
});
describe("when commit message summary line is longer than 72 characters", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): this adds TONS of new features such as",
"walking your dog, buying groceries, paying your bills, and",
"more!",
].join(" "),
);
});
it("rejects commit message w/ appropriate error message", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: "⛔ Error: commit message summary line exceeds max length of 72 characters",
},
);
});
});
describe("when commit message contains comments", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): added lots of new features",
"",
"This adds a LOT of new features. Like, an incredible amount!",
"",
"# Please enter the commit message for your changes. Lines starting",
"# with '#' will be ignored, and an empty message aborts the commit.",
"#",
"# Date: Mon Aug 18 16:24:29 2025 +0000",
].join("\n"),
);
});
it("removes comments from the comment message", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a): added lots of new features",
"",
"This adds a LOT of new features. Like, an incredible amount!",
].join("\n"),
);
});
});
describe("when commit message is valid conventional commit but is improperly formatted", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat( subpackage-a ): added lots of new features",
"adds a lot of neat stuff",
"",
"you should check it out brah!",
"",
"BREAKING-CHANGE: It's gonna break production, gurranteed.",
"",
"",
"authored-By: Someone",
" ",
].join("\n"),
);
});
it("formats commit message to normalize whitespace, capitalization & punctuation", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a)!: added lots of new features",
"",
"Adds a lot of neat stuff.",
"",
"You should check it out brah!",
"",
"BREAKING CHANGE: it's gonna break production, gurranteed",
"",
"Authored-by: Someone",
].join("\n"),
);
});
});
describe("when commit message body exceeds max line length of 72 characters", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): added lots of new features",
"",
[
"Lorem ipsum dolor sit amet, consectetur adipiscing",
"elit, sed do eiusmod tempor incididunt ut labore et",
"dolore magna aliqua. Ut enim ad minim veniam, quis",
"https://example.com/some/very/very/very/very/long-path/with-hyphens.html",
"nostrud exercitation ullamco laboris nisi ut aliquip",
"ex ea commodo consequat.",
].join(" "),
"",
"",
].join("\n"),
);
});
it("wraps message body at 72 character rule where feasible", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a): added lots of new features",
"",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod",
"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim",
"veniam, quis",
"https://example.com/some/very/very/very/very/long-path/with-hyphens.html",
"nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo",
"consequat.",
].join("\n"),
);
});
});
describe('when commit message has "BREAKING CHANGE" footer', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"fix(@websnacksjs/conventional): changes a few things",
"",
"BREAKING CHANGE: this will break production, guaranteed",
].join("\n"),
);
});
it("marks commit message summary as breaking", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
const [firstLine] = await fs
.readFile(messageFile, "utf-8")
.then((message) => message.split("\n"));
assert.equal(
firstLine,
"fix(@websnacksjs/conventional)!: changes a few things",
);
});
});
describe("when user provides a config file that fails to load", () => {
let configFile: URL;
before(async () => {
configFile = new URL("./conventional.config.js", tempDir);
await fs.writeFile(
configFile,
code`
export default =
`.toString(),
"utf-8",
);
});
after(async () => {
await fs.rm(configFile);
});
it("provides an appropriate error message", async ({ signal }) => {
const messageFile = await mockCommitMessage(
["feat(good): this is a good thing, probably"].join("\n"),
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
`⛔ Error: failed to load config from ./conventional.config.js:`,
`Unexpected token '='`,
].join(" "),
},
);
});
});
describe("when user provides config file w/ validateCommitMessage(...)", () => {
let configFile: URL;
before(async () => {
configFile = new URL("./conventional.config.js", tempDir);
await fs.writeFile(
configFile,
code`
export default {
validateCommitMessage(message) {
if (message.scope !== "good") {
throw new Error(\`only "good" is accepted as a valid scope\`)
}
}
};
`.toString(),
"utf-8",
);
});
after(async () => {
await fs.rm(configFile);
});
it("rejects commit message that DON'T satisfy user validation function", async ({
signal,
}) => {
const messageFile = await mockCommitMessage(
"feat(bad): adds a terrible new feature, beware",
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
`⛔ Error: user-defined validateCommitMessage(...) rejected commit message:`,
`only "good" is accepted as a valid scope`,
].join(" "),
},
);
});
it("approves commit message that DO satisfy user validation function", async ({
signal,
}) => {
const messageFile = await mockCommitMessage(
"feat(good): adds a terrible new feature, beware",
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stdout: "",
},
);
});
});
});

View file

@ -1,8 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["./**/*"]
}

View file

@ -1,8 +0,0 @@
{
"extends": ["../../tsconfig.common.json"],
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src"]
}

1
packages/i18n-astro/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
!/globals.d.ts

View file

@ -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 <mgeorge@technopolitica.com>",
"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"
}
}

View file

@ -0,0 +1,4 @@
export {
default,
type I18nAstroOptions,
} from "./integration.js";

View file

@ -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<string, Code>,
)}>;
export default i18n;
}
`.toString(),
});
},
},
};
}

View file

@ -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();
},
},
};
};

View file

@ -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;
`,
});
}

View file

@ -0,0 +1,8 @@
{
"extends": [
"../../tsconfig.common.json",
"@tsconfig/node-lts/tsconfig.json"
],
"include": ["src"],
"references": [{ "path": "../i18n" }]
}

View file

@ -1 +1 @@
!/tests/fixtures/**/*.json
!/tests/messages/*/*.json

View file

@ -21,7 +21,7 @@
],
"scripts": {
"build": "tsc --build",
"test": "tsc --noEmit --project tests/tsconfig.json && node --experimental-strip-types --disable-warning=ExperimentalWarning --test --test-reporter=dot",
"test": "tsc --noEmit --project tests/tsconfig.json && node --experimental-strip-types --disable-warning=ExperimentalWarning --test",
"pretest": "npm run build",
"prepack": "npm run build"
}

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import type I18n from "@websnacksjs/i18n";
import { type Fixtures, withFixture } from "../fixtures.js";
describe("i18n.supportedLocales()", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base", {
supportedLocales: ["en", "fr", "fr-Arab"],
});
});
it("returns maximized locales for all declared, supported locales", () => {
assert.deepEqual(i18n.supportedLocales(), [
"en-Latn-US",
"fr-Latn-FR",
"fr-Arab-FR",
]);
});
});
describe("i18n.loadMessages(...)", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base");
});
it("guesses region of locales w/o region tags", async () => {
const t = await i18n.loadMessages({
locale: "fr-Latn",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("guesses script of locales w/ region tags", async () => {
const t = await i18n.loadMessages({
locale: "fr-FR",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("guesses script & region of bare language locales", async () => {
const t = await i18n.loadMessages({
locale: "fr",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("does NOT fallback to bare language locales", async () => {
await assert.rejects(
i18n.loadMessages({
locale: "en-Arab",
}),
{
message:
'no declared locale matches requested locale of "en-Arab-US" (maximized from "en-Arab")',
},
);
});
});

View file

@ -2,8 +2,8 @@ import I18n, { type I18nOptions } from "@websnacksjs/i18n";
export type Fixtures = {
base: {
common: typeof import("./fixtures/base/en/common.json");
drama: typeof import("./fixtures/base/en/drama.json");
common: typeof import("./fixtures/base/messages/en/common.json");
drama: typeof import("./fixtures/base/messages/en/drama.json");
};
};
@ -17,7 +17,7 @@ export const withFixture = <F extends keyof Fixtures>(
supportedLocales: ["en", "fr", "fr-Arab"],
namespaces: ["drama"],
messagesUrlTemplate: new URL(
"./fixtures/base/:locale/:namespace.json",
"./fixtures/base/messages/:locale/:namespace.json",
import.meta.url,
),
...(overrides as Partial<I18nOptions<Fixtures["base"]>>),

View file

@ -1,7 +0,0 @@
{
"oh hai": "Oh hai {{name}}!",
"denial": "I did not hit her. I did not.",
"flower shop": {
"doggy": "hello doggy!"
}
}

View file

@ -1,3 +0,0 @@
{
"tearing me apart": "You're tearing me apart, Lisa!"
}

View file

@ -1,7 +0,0 @@
{
"oh hai": "أوه سالو، \u2066{{name}}\u2069!",
"denial": "جُ ن لي با فرابيه. جُ ن لي با.",
"flower shop": {
"doggy": "سالو توتو!"
}
}

View file

@ -1,3 +0,0 @@
{
"tearing me apart": "تو م ديشير، ليزا!"
}

View file

@ -1,7 +0,0 @@
{
"oh hai": "Oh salut, Mark !",
"denial": "Je ne l'ai pas frappée. Je ne l'ai pas.",
"flower shop": {
"doggy": "Salut toutou !"
}
}

View file

@ -1,3 +0,0 @@
{
"tearing me apart": "Tu me déchires, Lisa !"
}

View file

@ -2,7 +2,6 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"rootDir": "."
},

View file

@ -1,20 +1,11 @@
{
"extends": ["./tsconfig.common.json"],
"compilerOptions": {
"rootDir": ".",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowJs": true,
"noEmit": true,
"checkJs": true
},
"include": ["./*.config.js"],
"files": [],
"references": [
{
"path": "./packages/conventional"
"path": "./packages/i18n"
},
{
"path": "./packages/i18n"
"path": "./packages/i18n-astro"
}
]
}