Skip to content

Commit 8a762a1

Browse files
s00dcursoragent
andcommitted
fix(vitepress): address second cubic pass on #247
Per-app plugin install, stricter glob unwrap, routeName 2-arg, skip full-inline disk watch; I18nLink active ignores hash. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bf8c9a4 commit 8a762a1

8 files changed

Lines changed: 190 additions & 34 deletions

File tree

packages/vitepress/src/create.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,13 @@ export function createVitePressI18n(options: VitePressI18nOptions): CreateVitePr
9292

9393
applyRouteMessages(plugin, options.messages, options.routeMessages)
9494

95-
let adapter: VitePressRouterAdapter | null = null
96-
let installed = false
97-
let boundSyncHandler: ((to: string) => unknown) | null = null
98-
let chainedPrevious: ((to: string) => unknown) | undefined
95+
// Per Vue app (SSR / multiple VitePress apps sharing one createVitePressI18n result).
96+
const byApp = new WeakMap<object, {
97+
adapter: VitePressRouterAdapter
98+
boundSyncHandler: ((to: string) => unknown) | null
99+
chainedPrevious: ((to: string) => unknown) | undefined
100+
}>()
101+
let lastAdapter: VitePressRouterAdapter | null = null
99102

100103
const enhanceApp = (ctx: {
101104
app: App
@@ -104,8 +107,9 @@ export function createVitePressI18n(options: VitePressI18nOptions): CreateVitePr
104107
}) => {
105108
const { app, router } = ctx
106109

107-
if (!installed) {
108-
adapter = createVitePressRouterAdapter({
110+
let state = byApp.get(app)
111+
if (!state) {
112+
const adapter = createVitePressRouterAdapter({
109113
locales,
110114
defaultLocale,
111115
localeKeyToCode: options.localeKeyToCode,
@@ -116,15 +120,21 @@ export function createVitePressI18n(options: VitePressI18nOptions): CreateVitePr
116120
go: (href, navOptions) => router.go(href, navOptions),
117121
})
118122

119-
plugin.setRoutingStrategy(adapter)
123+
// Install first so `setRoutingStrategy` provides into this app (not a previous one).
120124
app.use(plugin)
121-
installed = true
125+
plugin.setRoutingStrategy(adapter)
126+
state = { adapter, boundSyncHandler: null, chainedPrevious: undefined }
127+
byApp.set(app, state)
128+
}
129+
else {
130+
plugin.setRoutingStrategy(state.adapter)
122131
}
132+
lastAdapter = state.adapter
123133

124-
if (!syncWithVitePress || !adapter) return
134+
if (!syncWithVitePress) return
125135

136+
const { adapter } = state
126137
const sync = (path = router.route.path) => {
127-
if (!adapter) return
128138
const nextLocale = adapter.getLocaleFromPath(path)
129139
if (plugin.global.getLocale() !== nextLocale) {
130140
plugin.global.locale = nextLocale
@@ -138,12 +148,12 @@ export function createVitePressI18n(options: VitePressI18nOptions): CreateVitePr
138148

139149
// Stay outermost: if base/user enhanceApp overwrote the hook, re-wrap on a later call.
140150
const current = router.onAfterRouteChange
141-
if (current !== boundSyncHandler) {
142-
chainedPrevious = typeof current === 'function' ? current : undefined
151+
if (current !== state.boundSyncHandler) {
152+
state.chainedPrevious = typeof current === 'function' ? current : undefined
143153
}
144-
boundSyncHandler = async (to: string) => {
145-
if (typeof chainedPrevious === 'function') {
146-
await chainedPrevious(to)
154+
state.boundSyncHandler = async (to: string) => {
155+
if (typeof state.chainedPrevious === 'function') {
156+
await state.chainedPrevious(to)
147157
}
148158
const path = to.startsWith('http')
149159
? (() => {
@@ -153,14 +163,14 @@ export function createVitePressI18n(options: VitePressI18nOptions): CreateVitePr
153163
: to
154164
sync(path)
155165
}
156-
router.onAfterRouteChange = boundSyncHandler
166+
router.onAfterRouteChange = state.boundSyncHandler
157167
}
158168

159169
return {
160170
i18n: plugin.global,
161171
plugin,
162172
get adapter() {
163-
return adapter
173+
return lastAdapter
164174
},
165175
enhanceApp,
166176
}

packages/vitepress/src/messages-from-glob.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { Translations } from '@i18n-micro/types'
22

3+
/**
4+
* Vite `import.meta.glob(..., { eager: true })` modules look like
5+
* `{ default: { …json } }` or `{ default: { … }, __esModule: true }`.
6+
* Do not treat a real dictionary that only has a `default` string key as a namespace.
7+
*/
38
function isModuleNamespace(mod: object): mod is { default: Translations } {
9+
if (!('default' in mod)) return false
410
const keys = Object.keys(mod)
5-
return keys.length > 0 && keys.every((key) => key === 'default' || key === '__esModule')
11+
if (!keys.every((key) => key === 'default' || key === '__esModule')) return false
12+
const value = (mod as { default: unknown }).default
13+
return value !== null && typeof value === 'object' && !Array.isArray(value)
614
}
715

816
/**
917
* Tiny helper if you still prefer `import.meta.glob` instead of `defineI18nTheme`.
10-
* Only unwraps `{ default: … }` Vite module namespaces — a dictionary that happens
11-
* to contain a `default` key is kept intact.
18+
* Only unwraps Vite module namespaces — dictionaries with a `default` key stay intact.
1219
*/
1320
export function messagesFromGlob(
1421
modules: Record<string, { default: Translations } | Translations>,

packages/vitepress/src/router/adapter.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export function getLocaleFromPath(
104104
/**
105105
* Route name for page-scoped dictionaries (`/guide/demo` → `guide-demo`).
106106
* Strips VitePress URL prefixes (keys), not only raw i18n codes.
107+
*
108+
* When `defaultLocale` is omitted (legacy 2-arg call), any segment that matches a
109+
* listed locale code is stripped — callers must not rely on `localeCodes[0]` as default.
107110
*/
108111
export function routeNameFromPath(
109112
path: string,
@@ -114,13 +117,14 @@ export function routeNameFromPath(
114117
const { pathname } = splitPathAndExtras(path)
115118
const segments = pathname.split('/').filter(Boolean)
116119
const first = segments[0]
117-
const prefixToCode = buildUrlPrefixToCode(
118-
localeCodes,
119-
defaultLocale ?? localeCodes[0] ?? 'en',
120-
localeKeyToCode,
121-
)
122-
if (first !== undefined && prefixToCode.has(first)) {
123-
segments.shift()
120+
if (first !== undefined) {
121+
if (defaultLocale !== undefined) {
122+
const prefixToCode = buildUrlPrefixToCode(localeCodes, defaultLocale, localeKeyToCode)
123+
if (prefixToCode.has(first)) segments.shift()
124+
}
125+
else if (localeCodes.includes(first)) {
126+
segments.shift()
127+
}
124128
}
125129
if (segments.length === 0) return 'index'
126130
return segments.join('-').replace(/\.html$/, '')

packages/vitepress/src/with-i18n-micro.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,11 @@ function createI18nMicroVitePlugin(options: WithI18nMicroOptions): Plugin {
143143
let inlineRoot = options.messages ?? {}
144144
let inlineRoutes = options.routeMessages ?? {}
145145
let debounceTimer: ReturnType<typeof setTimeout> | undefined
146+
const needsDiskReload = () => !options.messages || !options.routeMessages
146147

147148
const reloadInlineFromDisk = () => {
149+
// Fully inline config: skip disk I/O (and avoid spurious JSON parse errors).
150+
if (!needsDiskReload()) return
148151
const loaded = loadTranslationBuckets({
149152
rootDir,
150153
translationDir,
@@ -168,14 +171,17 @@ function createI18nMicroVitePlugin(options: WithI18nMicroOptions): Plugin {
168171
if (useInline) {
169172
if (options.messages) inlineRoot = options.messages
170173
if (options.routeMessages) inlineRoutes = options.routeMessages
171-
if (!options.messages || !options.routeMessages) {
174+
if (needsDiskReload()) {
172175
reloadInlineFromDisk()
173176
}
174177
if (options.messages) inlineRoot = options.messages
175178
if (options.routeMessages) inlineRoutes = options.routeMessages
176179
}
177180
},
178181
configureServer(server) {
182+
// Both maps provided inline — nothing to watch on disk.
183+
if (useInline && !needsDiskReload()) return
184+
179185
const dir = resolve(rootDir, translationDir)
180186
if (!existsSync(dir)) return
181187

@@ -194,14 +200,14 @@ function createI18nMicroVitePlugin(options: WithI18nMicroOptions): Plugin {
194200
}
195201

196202
// add/unlink need virtual module regen; change is usually handled by JSON import HMR,
197-
// but we still invalidate when using inline payload.
203+
// but we still invalidate when using inline payload that still reads disk.
198204
server.watcher.on('add', (file) => {
199205
if (file.startsWith(dir) && file.endsWith('.json')) invalidate()
200206
})
201207
server.watcher.on('unlink', (file) => {
202208
if (file.startsWith(dir) && file.endsWith('.json')) invalidate()
203209
})
204-
if (useInline) {
210+
if (useInline && needsDiskReload()) {
205211
server.watcher.on('change', (file) => {
206212
if (file.startsWith(dir) && file.endsWith('.json')) invalidate()
207213
})

packages/vitepress/tests/adapter.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import { createVitePressRouterAdapter } from '../src/router/adapter'
2+
import { createVitePressRouterAdapter, routeNameFromPath } from '../src/router/adapter'
33
import { createI18nRoutingFromAdapter } from '../src/router/i18n-routing'
44

55
const locales = [
@@ -71,6 +71,20 @@ describe('createVitePressRouterAdapter', () => {
7171
})
7272
})
7373

74+
describe('routeNameFromPath', () => {
75+
it('strips locale codes without guessing default from localeCodes[0]', () => {
76+
// Codes listed default-last — must still strip /fr/
77+
expect(routeNameFromPath('/fr/guide', ['fr', 'en'])).toBe('guide')
78+
expect(routeNameFromPath('/guide', ['fr', 'en'])).toBe('guide')
79+
})
80+
81+
it('uses defaultLocale + localeKeyToCode when provided', () => {
82+
expect(
83+
routeNameFromPath('/fr/guide', ['en-US', 'fr-FR'], 'en-US', { root: 'en-US', fr: 'fr-FR' }),
84+
).toBe('guide')
85+
})
86+
})
87+
7488
describe('createI18nRoutingFromAdapter', () => {
7589
const i18nRouting = createI18nRoutingFromAdapter({
7690
defaultLocale: 'en',

packages/vitepress/tests/with-i18n-micro.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
14
import { afterEach, describe, expect, it, vi } from 'vitest'
25
import { withI18nMicro, warnLocaleMismatch, type VitePressUserConfigLike } from '../src/with-i18n-micro'
36
import { messagesFromGlob } from '../src/messages-from-glob'
@@ -84,6 +87,73 @@ describe('withI18nMicro', () => {
8487
expect(String(warn.mock.calls[0]?.[0])).toContain('"de"')
8588
expect(String(warn.mock.calls[0]?.[0])).not.toContain('"root"')
8689
})
90+
91+
it('loads root from disk when only routeMessages are inline', () => {
92+
const dir = mkdtempSync(join(tmpdir(), 'i18n-vp-inline-'))
93+
try {
94+
writeFileSync(join(dir, 'en.json'), JSON.stringify({ fromDisk: true }))
95+
const result = withI18nMicro(
96+
{} as VitePressUserConfigLike,
97+
{
98+
locale: 'en',
99+
locales: [{ code: 'en' }],
100+
translationDir: dir,
101+
routeMessages: { home: { en: { page: 'inline' } } },
102+
warnOnLocaleMismatch: false,
103+
},
104+
)
105+
const plugins = (result.vite?.plugins ?? []) as Array<{
106+
name: string
107+
configResolved?: (c: { root: string }) => void
108+
load?: (id: string) => string | undefined
109+
resolveId?: (id: string) => string | undefined
110+
configureServer?: (server: unknown) => void
111+
}>
112+
const plugin = plugins.find((p) => p.name === 'vite-plugin-i18n-micro-vitepress')!
113+
plugin.configResolved?.({ root: dir })
114+
const id = plugin.resolveId!('virtual:i18n-micro/messages')!
115+
const src = plugin.load!(id)!
116+
expect(src).toContain('"fromDisk":true')
117+
expect(src).toContain('"page":"inline"')
118+
plugin.configureServer?.({
119+
watcher: { add: vi.fn(), on: vi.fn() },
120+
moduleGraph: { getModuleById: () => undefined },
121+
ws: { send: vi.fn() },
122+
})
123+
}
124+
finally {
125+
rmSync(dir, { recursive: true, force: true })
126+
}
127+
})
128+
129+
it('skips disk watchers when messages and routeMessages are both inline', () => {
130+
const result = withI18nMicro(
131+
{} as VitePressUserConfigLike,
132+
{
133+
locale: 'en',
134+
locales: [{ code: 'en' }],
135+
messages: { en: { a: '1' } },
136+
routeMessages: { home: { en: { b: '2' } } },
137+
warnOnLocaleMismatch: false,
138+
},
139+
)
140+
const plugins = (result.vite?.plugins ?? []) as Array<{
141+
name: string
142+
configResolved?: (c: { root: string }) => void
143+
configureServer?: (server: { watcher: { add: ReturnType<typeof vi.fn> } }) => void
144+
load?: (id: string) => string | undefined
145+
resolveId?: (id: string) => string | undefined
146+
}>
147+
const plugin = plugins.find((p) => p.name === 'vite-plugin-i18n-micro-vitepress')!
148+
plugin.configResolved?.({ root: process.cwd() })
149+
const add = vi.fn()
150+
plugin.configureServer?.({ watcher: { add } })
151+
expect(add).not.toHaveBeenCalled()
152+
const id = plugin.resolveId!('virtual:i18n-micro/messages')!
153+
const src = plugin.load!(id)!
154+
expect(src).toContain('"a":"1"')
155+
expect(src).toContain('"b":"2"')
156+
})
87157
})
88158

89159
describe('messagesFromGlob', () => {
@@ -96,6 +166,20 @@ describe('messagesFromGlob', () => {
96166
expect(messages.fr).toEqual({ default: 'Defaut', hello: 'Bonjour' })
97167
})
98168

169+
it('keeps a one-key dictionary whose default value is a string', () => {
170+
const messages = messagesFromGlob({
171+
'/x/en.json': { default: 'Default' },
172+
})
173+
expect(messages.en).toEqual({ default: 'Default' })
174+
})
175+
176+
it('keeps an __esModule-only object as a dictionary', () => {
177+
const messages = messagesFromGlob({
178+
'/x/en.json': { __esModule: true } as unknown as { default: never },
179+
})
180+
expect(messages.en).toEqual({ __esModule: true })
181+
})
182+
99183
it('unwraps Vite module namespaces', () => {
100184
const messages = messagesFromGlob({
101185
'/x/de.json': { default: { hello: 'Hallo' } },
@@ -195,4 +279,35 @@ describe('createVitePressI18n', () => {
195279
expect(userHook).toHaveBeenCalled()
196280
expect(i18n.getLocale()).toBe('fr')
197281
})
282+
283+
it('installs the plugin on every Vue app', () => {
284+
const { enhanceApp } = createVitePressI18n({
285+
locale: 'en',
286+
defaultLocale: 'en',
287+
locales: [{ code: 'en' }, { code: 'fr' }],
288+
messages: { en: { hi: 'Hi' }, fr: { hi: 'Salut' } },
289+
syncWithVitePress: false,
290+
})
291+
292+
const makeApp = () => ({
293+
use: vi.fn(),
294+
provide: vi.fn(),
295+
config: { globalProperties: {} as Record<string, unknown> },
296+
component: vi.fn(),
297+
})
298+
299+
const makeRouter = (path: string) => ({
300+
route: { path },
301+
go: vi.fn(),
302+
onAfterRouteChange: undefined as ((to: string) => unknown) | undefined,
303+
})
304+
305+
const app1 = makeApp()
306+
const app2 = makeApp()
307+
enhanceApp({ app: app1 as never, router: makeRouter('/') as never })
308+
enhanceApp({ app: app2 as never, router: makeRouter('/fr/') as never })
309+
310+
expect(app1.use).toHaveBeenCalledTimes(1)
311+
expect(app2.use).toHaveBeenCalledTimes(1)
312+
})
198313
})

packages/vue/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@i18n-micro/vue",
3-
"version": "1.3.10",
3+
"version": "1.3.11",
44
"description": "Vue 3 bindings for i18n-micro — composables, components, and routing helpers.",
55
"keywords": [
66
"i18n",

packages/vue/src/components/i18n-link.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ export const I18nLink = defineComponent({
5757
return false
5858
}
5959

60-
const currentPath = routerStrategy.getCurrentPath().replace(/\/$/, '')
61-
const linkPath = targetPath.value.replace(/\/$/, '')
60+
const currentPath = routerStrategy.getCurrentPath().replace(/#.*$/, '').replace(/\/$/, '')
61+
const linkPath = targetPath.value.replace(/#.*$/, '').replace(/\/$/, '')
6262

6363
return currentPath === linkPath
6464
})

0 commit comments

Comments
 (0)