@dualkit/convert 0.1.0-beta.0 → 0.2.0-beta.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 CHANGED
@@ -94,11 +94,12 @@ Convert flags these in the report instead of guessing:
94
94
  It refuses, with a one-line reason, when:
95
95
 
96
96
  - The app is not Next.js App Router or Vite React. The Pages Router, Create React App, Remix, Vue, Svelte and Angular are refused.
97
- - The app already has its own middleware or proxy file, or files with the names convert would write.
97
+ - The app already has files with the names convert would write, such as `app-middleware`.
98
98
  - The root layout is a client component, has no `<html>` element, or takes its props in a form convert cannot add params to.
99
99
 
100
100
  ## Known gaps
101
101
 
102
+ - If the app has its own middleware or proxy file, convert moves it to `app-middleware` and runs it first, on the path without its locale. We tested this with a hand-written login check on Next.js 15. Clerk and other auth libraries are untested, so check your login after converting.
102
103
  - Convert does not place the language switch, because that would change the English look. It writes `components/dualkit-language-switch.tsx` and tells you to add it.
103
104
  - Next.js sends a visitor whose browser asks for Arabic from an English URL to the Arabic page. That is next-intl's locale detection, and convert leaves it on.
104
105
 
package/dist/index.cjs CHANGED
@@ -478,6 +478,15 @@ async function applyPlan(disk, plan) {
478
478
  return { touched, edits: edits2, writes, perFile };
479
479
  }
480
480
  var json = (value) => JSON.stringify(value, null, 2) + "\n";
481
+ var isMessages = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
482
+ function keepHandAdded(existing, generated) {
483
+ const kept = Object.entries(existing).map(([key, value]) => {
484
+ const fresh = generated[key];
485
+ if (fresh === void 0) return [key, value];
486
+ return [key, isMessages(value) && isMessages(fresh) ? keepHandAdded(value, fresh) : fresh];
487
+ });
488
+ return { ...generated, ...Object.fromEntries(kept) };
489
+ }
481
490
  var messagesFiles = (ledger) => ({
482
491
  [messagesPath("en")]: json(messagesFor(ledger, "en")),
483
492
  [messagesPath("ar")]: json(messagesFor(ledger, "ar"))
@@ -1686,6 +1695,88 @@ export const config = {
1686
1695
  matcher: '/((?!(?:api|_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1687
1696
  }
1688
1697
  `;
1698
+ var composedMiddlewareFile = (routing, matcher, own) => `import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
1699
+ import createMiddleware from 'next-intl/middleware'
1700
+ import { routing } from '${routing}'
1701
+ import { matchesApp, type Matcher } from '${matcher}'
1702
+ import * as own from '${own}'
1703
+
1704
+ const intl = createMiddleware(routing)
1705
+ type OwnHandler = (request: NextRequest, event: NextFetchEvent) => Response | null | undefined | Promise<Response | null | undefined>
1706
+ // A spread, so the bundler does not warn about the export names this app does not use.
1707
+ const exported: Record<string, unknown> = { ...own }
1708
+ const candidate = exported.default ?? exported.middleware ?? exported.proxy
1709
+ if (typeof candidate !== 'function') throw new Error('Dualkit could not find the middleware function exported by ${own}.')
1710
+ const ownHandler = candidate as OwnHandler
1711
+ const ownMatcher = (exported as { config?: { matcher?: Matcher } }).config?.matcher
1712
+ const OWN_ONLY = /^\\/(?:api|trpc)(?:\\/|$)/
1713
+
1714
+ export default async function middleware(request: NextRequest, event: NextFetchEvent) {
1715
+ const url = request.nextUrl.clone()
1716
+ const [, first = '', ...rest] = url.pathname.split('/')
1717
+ if ((routing.locales as readonly string[]).includes(first)) url.pathname = \`/\${rest.join('/')}\`
1718
+ const result = matchesApp(url.pathname, ownMatcher) ? await ownHandler(new NextRequest(url, request), event) : null
1719
+ // Anything but a plain "continue" is the app's answer: a redirect, a rewrite, a 401 or a page. Keep it as it is.
1720
+ if (result && result.headers.get('x-middleware-next') !== '1') return result
1721
+ if (OWN_ONLY.test(url.pathname)) return result ?? NextResponse.next()
1722
+ const response = intl(request)
1723
+ if (result) {
1724
+ for (const cookie of result.headers.getSetCookie()) response.headers.append('set-cookie', cookie)
1725
+ // Headers the app set for the browser, such as a content security policy.
1726
+ result.headers.forEach((value, key) => { if (key !== 'set-cookie' && !key.startsWith('x-middleware-')) response.headers.set(key, value) })
1727
+ const overridden = result.headers.get('x-middleware-override-headers')
1728
+ if (overridden) {
1729
+ const names = new Set([...(response.headers.get('x-middleware-override-headers') ?? '').split(','), ...overridden.split(',')].filter(Boolean))
1730
+ response.headers.set('x-middleware-override-headers', [...names].join(','))
1731
+ result.headers.forEach((value, key) => { if (key.startsWith('x-middleware-request-')) response.headers.set(key, value) })
1732
+ }
1733
+ }
1734
+ return response
1735
+ }
1736
+
1737
+ export const config = {
1738
+ matcher: '/((?!(?:_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1739
+ }
1740
+ `;
1741
+ var APP_MATCHER = String.raw`/** Whether a path is covered by an app's own middleware matcher. No matcher means every path. */
1742
+ export type Matcher = string | { source: string } | (string | { source: string })[]
1743
+
1744
+ /** An entry with has or missing conditions is treated as matching, so the app's middleware runs rather than being skipped. */
1745
+ export function matchesApp(pathname: string, matcher: Matcher | undefined): boolean {
1746
+ if (matcher === undefined) return true
1747
+ return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => toRegExp(typeof entry === 'string' ? entry : entry.source).test(pathname))
1748
+ }
1749
+
1750
+ function toRegExp(pattern: string): RegExp {
1751
+ let out = ''
1752
+ let i = 0
1753
+ while (i < pattern.length) {
1754
+ const char = pattern[i]!
1755
+ const param = char === '/' ? /^\/:[A-Za-z0-9_]+([*+?]?)/.exec(pattern.slice(i)) : null
1756
+ if (param && pattern[i + param[0].length] === '(') {
1757
+ out += '/'
1758
+ i += param[0].length
1759
+ } else if (param) {
1760
+ out += { '*': '(?:/.*)?', '+': '/.+', '?': '(?:/[^/]+)?', '': '/[^/]+' }[param[1]!]
1761
+ i += param[0].length
1762
+ } else if (char === '(') {
1763
+ let depth = 0
1764
+ let j = i
1765
+ for (; j < pattern.length; j++) {
1766
+ if (pattern[j] === '\\') j++
1767
+ else if (pattern[j] === '(') depth++
1768
+ else if (pattern[j] === ')' && --depth === 0) break
1769
+ }
1770
+ out += pattern.slice(i, j + 1)
1771
+ i = j + 1
1772
+ } else {
1773
+ out += char.replace(/[.*+?^$|{}[\]\\]/g, '\\$&')
1774
+ i++
1775
+ }
1776
+ }
1777
+ return new RegExp('^' + out + (out.endsWith('/') ? '' : '/?') + '$')
1778
+ }
1779
+ `;
1689
1780
  var switchFile = (navigation) => `'use client'
1690
1781
 
1691
1782
  import { useLocale } from 'next-intl'
@@ -1738,16 +1829,24 @@ function alreadyWired(project, mode, appDir, files, paths) {
1738
1829
  const wired = (files(project.entry) ?? "").includes("NextIntlClientProvider") && files(`${sourceBase(project)}i18n/request.ts`) !== null;
1739
1830
  return wired ? project.entry : null;
1740
1831
  }
1741
- function guardMiddleware(base, ours, text, files) {
1832
+ function findMiddleware(base, files) {
1833
+ const found = [];
1742
1834
  for (const name of ["middleware", "proxy"]) {
1743
1835
  for (const ext of ["ts", "js", "mjs", "tsx", "jsx"]) {
1744
1836
  const path = `${base}${name}.${ext}`;
1745
- const existing = files(path);
1746
- if (existing === null || path === ours && existing === text) continue;
1747
- throw new UnsupportedError(`This app already has ${path}. Convert cannot merge the next-intl middleware into it, so it stopped before changing anything.`);
1837
+ const text = files(path);
1838
+ if (text !== null) found.push({ path, text });
1748
1839
  }
1749
1840
  }
1841
+ if (found.length > 1) throw new UnsupportedError(`This app has both ${found[0].path} and ${found[1].path}. Convert cannot tell which one to compose with next-intl, so it stopped before changing anything.`);
1842
+ const only = found[0] ?? null;
1843
+ if (only && !EXPORTS_HANDLER.test(only.text)) {
1844
+ throw new UnsupportedError(`Convert could not find the middleware function exported from ${only.path}, so it stopped before changing anything.`);
1845
+ }
1846
+ return only;
1750
1847
  }
1848
+ var EXPORTS_HANDLER = /export\s+default\b|export\s+(?:async\s+)?function\s+(?:middleware|proxy)\b|export\s+(?:const|let|var)\s+(?:middleware|proxy)\b|export\s*\{[^}]*\b(?:middleware|proxy|default)\b[^}]*\}/;
1849
+ var movedMiddlewarePath = (base, existing) => `${base}app-middleware.${existing.split(".").pop()}`;
1751
1850
  var merge = (into, from) => {
1752
1851
  const out = { ...into };
1753
1852
  for (const [path, edits2] of Object.entries(from)) out[path] = [...out[path] ?? [], ...edits2];
@@ -1757,8 +1856,13 @@ function bilingualParts(project, appDir, files, paths) {
1757
1856
  const base = sourceBase(project);
1758
1857
  const { moves, keptPages } = routeMoves(appDir, paths);
1759
1858
  const middlewarePath = `${base}${nextMajor(files) >= 16 ? "proxy" : "middleware"}.ts`;
1760
- const middleware = middlewareFile(specifierTo(middlewarePath, `${base}i18n/routing.ts`));
1761
- guardMiddleware(base, middlewarePath, middleware, files);
1859
+ const own = findMiddleware(base, files);
1860
+ const routingSpec = specifierTo(middlewarePath, `${base}i18n/routing.ts`);
1861
+ const ownMoved = own ? movedMiddlewarePath(base, own.path) : null;
1862
+ if (ownMoved && files(ownMoved) !== null) {
1863
+ throw new UnsupportedError(`This app already has ${ownMoved}. Convert needs that name for the app's own middleware, so move or remove that file and run convert again.`);
1864
+ }
1865
+ const middleware = ownMoved ? composedMiddlewareFile(routingSpec, specifierTo(middlewarePath, `${base}i18n/app-matcher.ts`), specifierTo(middlewarePath, ownMoved)) : middlewareFile(routingSpec);
1762
1866
  const catchAll = topCatchAll(appDir, moves, paths);
1763
1867
  const flags = keptPages.map((page) => flagAt(
1764
1868
  page,
@@ -1766,6 +1870,14 @@ function bilingualParts(project, appDir, files, paths) {
1766
1870
  `${page} stays outside [locale], because the middleware skips its path, so it has no Arabic version.`,
1767
1871
  "Move the page to a path the middleware matches, or translate it by hand."
1768
1872
  ));
1873
+ if (own && ownMoved) {
1874
+ flags.push(flagAt(
1875
+ ownMoved,
1876
+ "info",
1877
+ `${own.path} moved to ${ownMoved}, and the new ${middlewarePath} runs it first on the path without its locale prefix, then next-intl.`,
1878
+ "Check the app middleware still behaves as before and that its auth checks still hold under /ar. Any answer other than a plain continue (a redirect, rewrite or 401) is returned as it is. Paths under /api and /trpc skip next-intl. Config options other than matcher, such as runtime, are not carried over, and has or missing conditions on a matcher entry are ignored, so the middleware runs on those paths."
1879
+ ));
1880
+ }
1769
1881
  if (catchAll) {
1770
1882
  flags.push(flagAt(
1771
1883
  catchAll,
@@ -1778,11 +1890,13 @@ function bilingualParts(project, appDir, files, paths) {
1778
1890
  [`${base}i18n/routing.ts`]: ROUTING,
1779
1891
  [`${base}i18n/navigation.ts`]: NAVIGATION,
1780
1892
  [middlewarePath]: middleware,
1893
+ ...own ? { [`${base}i18n/app-matcher.ts`]: APP_MATCHER } : {},
1781
1894
  [switchPath(project)]: switchFile(specifierTo(switchPath(project), `${base}i18n/navigation.ts`)),
1782
1895
  ...catchAll ? {} : { [`${appDir}/[locale]/[...rest]/page.tsx`]: CATCH_ALL }
1783
1896
  };
1784
1897
  const rewrites = importRewrites(appDir, moves, paths, files, readAliases(files));
1785
- return { moves, writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1898
+ const ownMove = own && ownMoved ? [[own.path, ownMoved]] : [];
1899
+ return { moves: [...moves, ...ownMove], writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1786
1900
  }
1787
1901
  function wireNextIntl(project, mode, files, paths) {
1788
1902
  const appDir = project.appDir ?? "app";
@@ -3112,7 +3226,18 @@ function pendingCheck(ledger, force) {
3112
3226
  }
3113
3227
  function writeMessages(root, ledger) {
3114
3228
  const disk = { root, created: [] };
3115
- for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, text);
3229
+ for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3230
+ }
3231
+ function mergedMessages(root, path, generated) {
3232
+ const full = (0, import_node_path19.join)(root, path);
3233
+ if (!(0, import_node_fs11.existsSync)(full)) return generated;
3234
+ try {
3235
+ const existing = JSON.parse((0, import_node_fs11.readFileSync)(full, "utf8"));
3236
+ if (typeof existing !== "object" || existing === null) return generated;
3237
+ return JSON.stringify(keepHandAdded(existing, JSON.parse(generated)), null, 2) + "\n";
3238
+ } catch {
3239
+ return generated;
3240
+ }
3116
3241
  }
3117
3242
  async function englishChecks(root, base, routes, shoot) {
3118
3243
  const english2 = [];
package/dist/index.js CHANGED
@@ -443,6 +443,15 @@ async function applyPlan(disk, plan) {
443
443
  return { touched, edits: edits2, writes, perFile };
444
444
  }
445
445
  var json = (value) => JSON.stringify(value, null, 2) + "\n";
446
+ var isMessages = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
447
+ function keepHandAdded(existing, generated) {
448
+ const kept = Object.entries(existing).map(([key, value]) => {
449
+ const fresh = generated[key];
450
+ if (fresh === void 0) return [key, value];
451
+ return [key, isMessages(value) && isMessages(fresh) ? keepHandAdded(value, fresh) : fresh];
452
+ });
453
+ return { ...generated, ...Object.fromEntries(kept) };
454
+ }
446
455
  var messagesFiles = (ledger) => ({
447
456
  [messagesPath("en")]: json(messagesFor(ledger, "en")),
448
457
  [messagesPath("ar")]: json(messagesFor(ledger, "ar"))
@@ -1651,6 +1660,88 @@ export const config = {
1651
1660
  matcher: '/((?!(?:api|_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1652
1661
  }
1653
1662
  `;
1663
+ var composedMiddlewareFile = (routing, matcher, own) => `import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
1664
+ import createMiddleware from 'next-intl/middleware'
1665
+ import { routing } from '${routing}'
1666
+ import { matchesApp, type Matcher } from '${matcher}'
1667
+ import * as own from '${own}'
1668
+
1669
+ const intl = createMiddleware(routing)
1670
+ type OwnHandler = (request: NextRequest, event: NextFetchEvent) => Response | null | undefined | Promise<Response | null | undefined>
1671
+ // A spread, so the bundler does not warn about the export names this app does not use.
1672
+ const exported: Record<string, unknown> = { ...own }
1673
+ const candidate = exported.default ?? exported.middleware ?? exported.proxy
1674
+ if (typeof candidate !== 'function') throw new Error('Dualkit could not find the middleware function exported by ${own}.')
1675
+ const ownHandler = candidate as OwnHandler
1676
+ const ownMatcher = (exported as { config?: { matcher?: Matcher } }).config?.matcher
1677
+ const OWN_ONLY = /^\\/(?:api|trpc)(?:\\/|$)/
1678
+
1679
+ export default async function middleware(request: NextRequest, event: NextFetchEvent) {
1680
+ const url = request.nextUrl.clone()
1681
+ const [, first = '', ...rest] = url.pathname.split('/')
1682
+ if ((routing.locales as readonly string[]).includes(first)) url.pathname = \`/\${rest.join('/')}\`
1683
+ const result = matchesApp(url.pathname, ownMatcher) ? await ownHandler(new NextRequest(url, request), event) : null
1684
+ // Anything but a plain "continue" is the app's answer: a redirect, a rewrite, a 401 or a page. Keep it as it is.
1685
+ if (result && result.headers.get('x-middleware-next') !== '1') return result
1686
+ if (OWN_ONLY.test(url.pathname)) return result ?? NextResponse.next()
1687
+ const response = intl(request)
1688
+ if (result) {
1689
+ for (const cookie of result.headers.getSetCookie()) response.headers.append('set-cookie', cookie)
1690
+ // Headers the app set for the browser, such as a content security policy.
1691
+ result.headers.forEach((value, key) => { if (key !== 'set-cookie' && !key.startsWith('x-middleware-')) response.headers.set(key, value) })
1692
+ const overridden = result.headers.get('x-middleware-override-headers')
1693
+ if (overridden) {
1694
+ const names = new Set([...(response.headers.get('x-middleware-override-headers') ?? '').split(','), ...overridden.split(',')].filter(Boolean))
1695
+ response.headers.set('x-middleware-override-headers', [...names].join(','))
1696
+ result.headers.forEach((value, key) => { if (key.startsWith('x-middleware-request-')) response.headers.set(key, value) })
1697
+ }
1698
+ }
1699
+ return response
1700
+ }
1701
+
1702
+ export const config = {
1703
+ matcher: '/((?!(?:_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1704
+ }
1705
+ `;
1706
+ var APP_MATCHER = String.raw`/** Whether a path is covered by an app's own middleware matcher. No matcher means every path. */
1707
+ export type Matcher = string | { source: string } | (string | { source: string })[]
1708
+
1709
+ /** An entry with has or missing conditions is treated as matching, so the app's middleware runs rather than being skipped. */
1710
+ export function matchesApp(pathname: string, matcher: Matcher | undefined): boolean {
1711
+ if (matcher === undefined) return true
1712
+ return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => toRegExp(typeof entry === 'string' ? entry : entry.source).test(pathname))
1713
+ }
1714
+
1715
+ function toRegExp(pattern: string): RegExp {
1716
+ let out = ''
1717
+ let i = 0
1718
+ while (i < pattern.length) {
1719
+ const char = pattern[i]!
1720
+ const param = char === '/' ? /^\/:[A-Za-z0-9_]+([*+?]?)/.exec(pattern.slice(i)) : null
1721
+ if (param && pattern[i + param[0].length] === '(') {
1722
+ out += '/'
1723
+ i += param[0].length
1724
+ } else if (param) {
1725
+ out += { '*': '(?:/.*)?', '+': '/.+', '?': '(?:/[^/]+)?', '': '/[^/]+' }[param[1]!]
1726
+ i += param[0].length
1727
+ } else if (char === '(') {
1728
+ let depth = 0
1729
+ let j = i
1730
+ for (; j < pattern.length; j++) {
1731
+ if (pattern[j] === '\\') j++
1732
+ else if (pattern[j] === '(') depth++
1733
+ else if (pattern[j] === ')' && --depth === 0) break
1734
+ }
1735
+ out += pattern.slice(i, j + 1)
1736
+ i = j + 1
1737
+ } else {
1738
+ out += char.replace(/[.*+?^$|{}[\]\\]/g, '\\$&')
1739
+ i++
1740
+ }
1741
+ }
1742
+ return new RegExp('^' + out + (out.endsWith('/') ? '' : '/?') + '$')
1743
+ }
1744
+ `;
1654
1745
  var switchFile = (navigation) => `'use client'
1655
1746
 
1656
1747
  import { useLocale } from 'next-intl'
@@ -1703,16 +1794,24 @@ function alreadyWired(project, mode, appDir, files, paths) {
1703
1794
  const wired = (files(project.entry) ?? "").includes("NextIntlClientProvider") && files(`${sourceBase(project)}i18n/request.ts`) !== null;
1704
1795
  return wired ? project.entry : null;
1705
1796
  }
1706
- function guardMiddleware(base, ours, text, files) {
1797
+ function findMiddleware(base, files) {
1798
+ const found = [];
1707
1799
  for (const name of ["middleware", "proxy"]) {
1708
1800
  for (const ext of ["ts", "js", "mjs", "tsx", "jsx"]) {
1709
1801
  const path = `${base}${name}.${ext}`;
1710
- const existing = files(path);
1711
- if (existing === null || path === ours && existing === text) continue;
1712
- throw new UnsupportedError(`This app already has ${path}. Convert cannot merge the next-intl middleware into it, so it stopped before changing anything.`);
1802
+ const text = files(path);
1803
+ if (text !== null) found.push({ path, text });
1713
1804
  }
1714
1805
  }
1806
+ if (found.length > 1) throw new UnsupportedError(`This app has both ${found[0].path} and ${found[1].path}. Convert cannot tell which one to compose with next-intl, so it stopped before changing anything.`);
1807
+ const only = found[0] ?? null;
1808
+ if (only && !EXPORTS_HANDLER.test(only.text)) {
1809
+ throw new UnsupportedError(`Convert could not find the middleware function exported from ${only.path}, so it stopped before changing anything.`);
1810
+ }
1811
+ return only;
1715
1812
  }
1813
+ var EXPORTS_HANDLER = /export\s+default\b|export\s+(?:async\s+)?function\s+(?:middleware|proxy)\b|export\s+(?:const|let|var)\s+(?:middleware|proxy)\b|export\s*\{[^}]*\b(?:middleware|proxy|default)\b[^}]*\}/;
1814
+ var movedMiddlewarePath = (base, existing) => `${base}app-middleware.${existing.split(".").pop()}`;
1716
1815
  var merge = (into, from) => {
1717
1816
  const out = { ...into };
1718
1817
  for (const [path, edits2] of Object.entries(from)) out[path] = [...out[path] ?? [], ...edits2];
@@ -1722,8 +1821,13 @@ function bilingualParts(project, appDir, files, paths) {
1722
1821
  const base = sourceBase(project);
1723
1822
  const { moves, keptPages } = routeMoves(appDir, paths);
1724
1823
  const middlewarePath = `${base}${nextMajor(files) >= 16 ? "proxy" : "middleware"}.ts`;
1725
- const middleware = middlewareFile(specifierTo(middlewarePath, `${base}i18n/routing.ts`));
1726
- guardMiddleware(base, middlewarePath, middleware, files);
1824
+ const own = findMiddleware(base, files);
1825
+ const routingSpec = specifierTo(middlewarePath, `${base}i18n/routing.ts`);
1826
+ const ownMoved = own ? movedMiddlewarePath(base, own.path) : null;
1827
+ if (ownMoved && files(ownMoved) !== null) {
1828
+ throw new UnsupportedError(`This app already has ${ownMoved}. Convert needs that name for the app's own middleware, so move or remove that file and run convert again.`);
1829
+ }
1830
+ const middleware = ownMoved ? composedMiddlewareFile(routingSpec, specifierTo(middlewarePath, `${base}i18n/app-matcher.ts`), specifierTo(middlewarePath, ownMoved)) : middlewareFile(routingSpec);
1727
1831
  const catchAll = topCatchAll(appDir, moves, paths);
1728
1832
  const flags = keptPages.map((page) => flagAt(
1729
1833
  page,
@@ -1731,6 +1835,14 @@ function bilingualParts(project, appDir, files, paths) {
1731
1835
  `${page} stays outside [locale], because the middleware skips its path, so it has no Arabic version.`,
1732
1836
  "Move the page to a path the middleware matches, or translate it by hand."
1733
1837
  ));
1838
+ if (own && ownMoved) {
1839
+ flags.push(flagAt(
1840
+ ownMoved,
1841
+ "info",
1842
+ `${own.path} moved to ${ownMoved}, and the new ${middlewarePath} runs it first on the path without its locale prefix, then next-intl.`,
1843
+ "Check the app middleware still behaves as before and that its auth checks still hold under /ar. Any answer other than a plain continue (a redirect, rewrite or 401) is returned as it is. Paths under /api and /trpc skip next-intl. Config options other than matcher, such as runtime, are not carried over, and has or missing conditions on a matcher entry are ignored, so the middleware runs on those paths."
1844
+ ));
1845
+ }
1734
1846
  if (catchAll) {
1735
1847
  flags.push(flagAt(
1736
1848
  catchAll,
@@ -1743,11 +1855,13 @@ function bilingualParts(project, appDir, files, paths) {
1743
1855
  [`${base}i18n/routing.ts`]: ROUTING,
1744
1856
  [`${base}i18n/navigation.ts`]: NAVIGATION,
1745
1857
  [middlewarePath]: middleware,
1858
+ ...own ? { [`${base}i18n/app-matcher.ts`]: APP_MATCHER } : {},
1746
1859
  [switchPath(project)]: switchFile(specifierTo(switchPath(project), `${base}i18n/navigation.ts`)),
1747
1860
  ...catchAll ? {} : { [`${appDir}/[locale]/[...rest]/page.tsx`]: CATCH_ALL }
1748
1861
  };
1749
1862
  const rewrites = importRewrites(appDir, moves, paths, files, readAliases(files));
1750
- return { moves, writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1863
+ const ownMove = own && ownMoved ? [[own.path, ownMoved]] : [];
1864
+ return { moves: [...moves, ...ownMove], writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1751
1865
  }
1752
1866
  function wireNextIntl(project, mode, files, paths) {
1753
1867
  const appDir = project.appDir ?? "app";
@@ -3077,7 +3191,18 @@ function pendingCheck(ledger, force) {
3077
3191
  }
3078
3192
  function writeMessages(root, ledger) {
3079
3193
  const disk = { root, created: [] };
3080
- for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, text);
3194
+ for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3195
+ }
3196
+ function mergedMessages(root, path, generated) {
3197
+ const full = join11(root, path);
3198
+ if (!existsSync8(full)) return generated;
3199
+ try {
3200
+ const existing = JSON.parse(readFileSync7(full, "utf8"));
3201
+ if (typeof existing !== "object" || existing === null) return generated;
3202
+ return JSON.stringify(keepHandAdded(existing, JSON.parse(generated)), null, 2) + "\n";
3203
+ } catch {
3204
+ return generated;
3205
+ }
3081
3206
  }
3082
3207
  async function englishChecks(root, base, routes, shoot) {
3083
3208
  const english2 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dualkit/convert",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.2.0-beta.0",
4
4
  "description": "Converts an English React or Next.js app to Arabic on a new git branch.",
5
5
  "license": "MIT",
6
6
  "type": "module",