Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
4ee78ea
feat(extractor): prototype loader-based extraction via addContextDepe…
cursoragent Feb 20, 2026
3f36329
refactor(extractor): use loader-based extraction, remove parcel watcher
cursoragent Feb 20, 2026
31dfda2
feat(extractor): add extensive logging for loader-based extraction
cursoragent Feb 20, 2026
9b75abe
feat(extractor): add granularity logging, Tailwind research, incremen…
cursoragent Feb 20, 2026
f699a0a
Merge remote-tracking branch 'origin/feat/tree-shaking-messages' into…
amannn Feb 23, 2026
9b57938
fix: disable watcher-dependent tests, add messages dir context dep, s…
cursoragent Feb 23, 2026
8b0591a
feat(extractor): persist orphaned translations for restore when messa…
cursoragent Feb 23, 2026
59951df
refactor(extractor): cache ExtractionCompiler in loader, preserve orp…
cursoragent Feb 24, 2026
1380c05
revert(extractor): revert extractor changes except watcher removal
cursoragent Feb 24, 2026
389f53a
remove some caching, pass a few tests
amannn Feb 24, 2026
294483b
wip
amannn Feb 24, 2026
0b4e6f3
skip
amannn Feb 24, 2026
48e675f
wip
amannn Feb 24, 2026
f029e78
wip
amannn Feb 24, 2026
81f7ea1
wip
amannn Feb 24, 2026
5260441
wip
amannn Feb 24, 2026
28b060a
wip
amannn Feb 25, 2026
399e5bd
fix: restore CatalogLocales/CatalogManager behavior for e2e tests
cursoragent Feb 25, 2026
a92f37f
fix: add addContextDependency for messages dir to detect new/removed …
cursoragent Feb 25, 2026
035aeed
docs: add comment for addContextDependency(messagesDir)
cursoragent Feb 25, 2026
ce99dfd
wip
amannn Feb 25, 2026
c8719fd
wip
amannn Feb 25, 2026
91eb5a9
wip
amannn Feb 25, 2026
b2a4725
revert: remove CatalogManager changes from 399e5bd (not necessary)
cursoragent Feb 25, 2026
b66375c
migrate remaining unit tests of compiler suite
amannn Feb 25, 2026
90e846a
wip
amannn Feb 25, 2026
29f8850
fix: use type-only import for Entry from po-parser in e2e-extracted-c…
cursoragent Feb 25, 2026
cbd45ad
fix: compile custom codec to .js for e2e-extracted-custom
cursoragent Feb 25, 2026
e54bd0a
wip
amannn Feb 26, 2026
bc218f9
wip
amannn Feb 26, 2026
e04a1f4
wip
amannn Feb 26, 2026
d9259f2
wip
amannn Feb 26, 2026
65d8f0e
wip
amannn Feb 26, 2026
5370792
wip
amannn Feb 26, 2026
ed5cbf6
wip
amannn Feb 26, 2026
d92b086
wip
amannn Feb 26, 2026
d00c7ff
wip
amannn Feb 26, 2026
bfba539
wip
amannn Feb 26, 2026
9df6079
add instrumentation
amannn Feb 26, 2026
8d07d1f
wip
amannn Feb 26, 2026
6e0664e
fix: increase timeout for flaky e2e-extracted-po test in CI
cursoragent Feb 26, 2026
613b180
fix: increase e2e-extracted-po timeout to 30s for CI
cursoragent Feb 26, 2026
b6cab7a
fix: skip flaky e2e-extracted-po test that times out in CI
cursoragent Feb 26, 2026
592c266
debug: add timing and state logging for e2e-extracted-po expectCatalog
cursoragent Feb 26, 2026
0ce9166
fix: avoid race with file watcher in e2e-extracted-po test
cursoragent Feb 26, 2026
bc6edb3
fix: use networkidle before second goto to allow file watcher to process
cursoragent Feb 26, 2026
d4cbac2
avoid unnecessary writes
amannn Feb 26, 2026
fd6bb50
fix: touch en.po to force catalog loader invalidation in e2e test
cursoragent Feb 26, 2026
b7fdfad
fix: touch en.po twice with goto between to ensure loader invalidation
cursoragent Feb 26, 2026
f5019e2
fix: use cache-busting URL on second goto to avoid browser cache
cursoragent Feb 26, 2026
34dd02d
fix: wait for networkidle before second touch to allow first request …
cursoragent Feb 26, 2026
9f32347
debug: add timing and state logging for removes-refs test
cursoragent Feb 27, 2026
4374c93
fix: add 500ms wait in CI before goto to allow file watcher to proces…
cursoragent Feb 27, 2026
11d8db1
fix: increase CI wait to 2000ms
cursoragent Feb 27, 2026
c0e453b
fix: add wait before edit and content-changing touch for CI watcher
cursoragent Feb 27, 2026
9646a00
fix: use 3 touch+wait+goto cycles in CI to give watcher multiple chances
cursoragent Feb 27, 2026
d10e1c2
fix: wait 2s after touch for src watcher to detect edit before goto
cursoragent Feb 27, 2026
d75e4bb
fix: try 5s wait for CI watcher
cursoragent Feb 27, 2026
48291fe
fix: use fresh dev server in CI (reuseExistingServer: false)
cursoragent Feb 27, 2026
739ca51
fix: add page.reload in CI after goto to force second request
cursoragent Feb 27, 2026
d371623
revert: remove defensive workarounds from removes-refs test, restore …
cursoragent Feb 27, 2026
1439b6c
docs: add analysis of removes-refs test failure
cursoragent Feb 27, 2026
915cd37
wip
amannn Feb 27, 2026
837e0d3
wip
amannn Mar 5, 2026
75f72c8
wip
amannn Mar 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
cache: "pnpm"
- run: pnpm install

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/prerelease-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "pnpm"
node-version: 20.x
node-version: 24.x
registry-url: "https://registry.npmjs.org"
- run: pnpm install
- run: pnpm turbo run build --filter './packages/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
with:
cache: "pnpm"
node-version: 20.x
node-version: 24.x
registry-url: "https://registry.npmjs.org"
- run: npm install -g npm@latest # Trusted publishers
- run: pnpm install
Expand Down
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
# Agents

## Always
## Conventions

- Don't use single-character variable names, use descriptive names but keep them as short as possible.

## Workflows

### Always

- If running a test with vitest, use "run" to avoid being stuck in watch mode.
- When making a change to something in `./packages` and you want to test the updated behavior in consuming apps, you need to build the packages first (`pnpm -w build-packages`)

## When committing
### When committing

- Make sure ESLint and Prettier pass on changed files.
- Make sure all tests pass.

## When creating a PR
### When creating a PR

- When creating a PR, use conventional commit prefixes for the PR title (e.g. `fix: `, `feat: `, ...).
4 changes: 4 additions & 0 deletions e2e/extracted-custom/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.next/
playwright-report/
test-results/
151 changes: 151 additions & 0 deletions e2e/extracted-custom/POCodecSourceMessageKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import POParser from 'po-parser';
import type {Entry} from 'po-parser';
import {defineCodec} from 'next-intl/extractor';

type ExtractedMessage = {
id: string;
message: string;
description?: string;
references?: Entry['references'];
/** Allows for additional properties like .po flags to be read and later written. */
[key: string]: unknown;
};

export default defineCodec(() => {
const DEFAULT_METADATA = {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Transfer-Encoding': '8bit',
'X-Generator': 'next-intl'
};

const metadataByLocale = new Map();

return {
decode(content, context) {
const catalog = POParser.parse(content);
if (catalog.meta) {
metadataByLocale.set(context.locale, catalog.meta);
}
const messages =
catalog.messages || ([] as NonNullable<typeof catalog.messages>);

return messages.map((msg) => {
const {extractedComments, msgctxt, msgid, msgstr, ...rest} = msg;

// Necessary to restore the ID
if (!msgctxt) {
throw new Error('msgctxt is required');
}

if (extractedComments && extractedComments.length > 1) {
throw new Error(
`Multiple extracted comments are not supported. Found ${extractedComments.length} comments for msgid "${msgid}".`
);
}

return {
...rest,
id: msgctxt,
message: msgstr,
...(extractedComments &&
extractedComments.length > 0 && {
description: extractedComments[0]
})
};
});
},

encode(messages, context) {
const encodedMessages = getSortedMessages(messages).map((msg) => {
const sourceMessage = context.sourceMessagesById.get(msg.id)?.message;
if (!sourceMessage) {
throw new Error(
`Source message not found for id "${msg.id}" in locale "${context.locale}".`
);
}

// Store the hashed ID in msgctxt so we can restore it during decode
const {description, id, message, ...rest} = msg;
return {
...(description && {extractedComments: [description]}),
...rest,
msgctxt: id,
msgid: sourceMessage,
msgstr: message
};
});

return POParser.serialize({
meta: {
Language: context.locale,
...DEFAULT_METADATA,
...metadataByLocale.get(context.locale)
},
messages: encodedMessages
});
},

toJSONString(source, context) {
const parsed = this.decode(source, context);
const messagesObject = {};
for (const message of parsed) {
setNestedProperty(messagesObject, message.id, message.message);
}
return JSON.stringify(messagesObject);
}
};
});

// Essentialls lodash/set, but we avoid this dependency
function setNestedProperty(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
obj: Record<string, any>,
keyPath: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any
): void {
const keys = keyPath.split('.');
let current = obj;

for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (
!(key in current) ||
typeof current[key] !== 'object' ||
current[key] === null
) {
current[key] = {};
}
current = current[key];
}

current[keys[keys.length - 1]] = value;
}

function getSortedMessages(
messages: Array<ExtractedMessage>
): Array<ExtractedMessage> {
return messages.toSorted((messageA, messageB) => {
const refA = messageA.references?.[0];
const refB = messageB.references?.[0];

// No references: preserve original (extraction) order
if (!refA || !refB) return 0;

// Sort by path, then line. Same path+line: preserve original order
return compareReferences(refA, refB);
});
}

function compareReferences(
refA: NonNullable<Entry['references']>[number],
refB: NonNullable<Entry['references']>[number]
): number {
const pathCompare = localeCompare(refA.path, refB.path);
if (pathCompare !== 0) return pathCompare;
return (refA.line ?? 0) - (refB.line ?? 0);
}

function localeCompare(a: string, b: string) {
return a.localeCompare(b, 'en');
}
20 changes: 20 additions & 0 deletions e2e/extracted-custom/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {defineConfig} from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';

export default defineConfig([
...nextVitals,
...nextTs,
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
]
}
}
]);
17 changes: 17 additions & 0 deletions e2e/extracted-custom/messages/de.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
msgid ""
msgstr ""
"Language: de\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: next-intl\n"

#: src/app/page.tsx:9
msgctxt "NhX4DJ"
msgid "Hello"
msgstr "Hallo"

#: src/components/Footer.tsx:7
#: src/components/Greeting.tsx:7
msgctxt "+YJVTi"
msgid "Hey!"
msgstr ""
17 changes: 17 additions & 0 deletions e2e/extracted-custom/messages/en.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
msgid ""
msgstr ""
"Language: en\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: next-intl\n"

#: src/app/page.tsx:9
msgctxt "NhX4DJ"
msgid "Hello"
msgstr "Hello"

#: src/components/Footer.tsx:7
#: src/components/Greeting.tsx:7
msgctxt "+YJVTi"
msgid "Hey!"
msgstr "Hey!"
6 changes: 6 additions & 0 deletions e2e/extracted-custom/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
20 changes: 20 additions & 0 deletions e2e/extracted-custom/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin({
experimental: {
srcPath: './src',
extract: {sourceLocale: 'en'},
messages: {
path: './messages',
format: {
codec: './POCodecSourceMessageKey.ts',
extension: '.po'
},
locales: 'infer'
}
}
});

const config: NextConfig = {};
export default withNextIntl(config);
38 changes: 38 additions & 0 deletions e2e/extracted-custom/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "e2e-extracted-custom",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"lint": "eslint src && prettier src --check",
"build": "next build",
"start": "next start",
"test": "playwright test"
},
"dependencies": {
"next": "^16.0.10",
"next-intl": "workspace:*",
"po-parser": "^2.1.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"shared-ui": "workspace:*"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@playwright/test": "^1.51.1",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"eslint": "^9.38.0",
"eslint-config-next": "^16.0.10",
"prettier": "^3.3.3",
"typescript": "^5.5.3"
},
"prettier": {
"singleQuote": true,
"bracketSpacing": false,
"trailingComma": "none"
},
"engines": {
"node": ">=20.0.0"
}
}
18 changes: 18 additions & 0 deletions e2e/extracted-custom/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {defineConfig, devices} from '@playwright/test';

const PORT = process.env.CI ? 3023 : 3022;

export default defineConfig({
fullyParallel: false,
testDir: './tests',
timeout: 120_000,
use: {
...devices['Desktop Chrome'],
baseURL: `http://localhost:${PORT}`
},
webServer: {
command: `PORT=${PORT} pnpm dev`,
port: PORT,
reuseExistingServer: true
}
});
14 changes: 14 additions & 0 deletions e2e/extracted-custom/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {NextIntlClientProvider} from 'next-intl';
import {getLocale} from 'next-intl/server';

export default async function RootLayout({children}: LayoutProps<'/'>) {
const locale = await getLocale();

return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
14 changes: 14 additions & 0 deletions e2e/extracted-custom/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {useExtracted} from 'next-intl';
import Greeting from '@/components/Greeting';
import Footer from '@/components/Footer';

export default function Page() {
const t = useExtracted();
return (
<div>
<h1>{t('Hello')}</h1>
<Greeting />
<Footer />
</div>
);
}
8 changes: 8 additions & 0 deletions e2e/extracted-custom/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import {useExtracted} from 'next-intl';

export default function Footer() {
const t = useExtracted();
return <footer>{t('Hey!')}</footer>;
}
8 changes: 8 additions & 0 deletions e2e/extracted-custom/src/components/Greeting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import {useExtracted} from 'next-intl';

export default function Greeting() {
const t = useExtracted();
return <div>{t('Hey!')}</div>;
}
Loading
Loading