Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4,119 changes: 4,119 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/nextra-theme-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@tailwindcss/cli": "4.1.10",
"@tailwindcss/postcss": "4.1.10",
"@testing-library/react": "^16.0.0",
"@types/node": "^25.0.0",
"@types/react": "^19.1.8",
"@vitejs/plugin-react": "^4.3.4",
"esbuild-react-compiler-plugin": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/nextra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.3",
"@types/node": "^25.0.0",
"@types/react": "^19.1.8",
"@types/webpack": "^5.28.5",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
103 changes: 103 additions & 0 deletions packages/nextra/src/server/__tests__/meta-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { extractKeysFromSource } from '../meta-loader.js'

describe('meta-loader', () => {
describe('extractKeysFromSource', () => {
it('should extract keys in source order for numeric string keys', () => {
const source = `export default {
'1140': '11.4.0',
'1130': '11.3.0',
'1120': '11.2.0',
'1110-1112': '11.1.0 ~ 11.1.2',
'1100': '11.0.0',
'9190': '9.19.0',
}`
const keys = extractKeysFromSource(source)
expect(keys).toEqual(['1140', '1130', '1120', '1110-1112', '1100', '9190'])
})

it('should extract keys with double quotes', () => {
const source = `export default {
"foo": "Foo",
"bar": "Bar",
"baz": "Baz",
}`
const keys = extractKeysFromSource(source)
expect(keys).toEqual(['foo', 'bar', 'baz'])
})

it('should extract unquoted keys', () => {
const source = `export default {
intro: 'Introduction',
getting_started: 'Getting Started',
advanced: 'Advanced',
}`
const keys = extractKeysFromSource(source)
expect(keys).toEqual(['intro', 'getting_started', 'advanced'])
})

it('should handle mixed quote styles', () => {
const source = `export default {
'single': 'Single Quotes',
"double": "Double Quotes",
unquoted: 'Unquoted Key',
}`
const keys = extractKeysFromSource(source)
expect(keys).toEqual(['single', 'double', 'unquoted'])
})

it('should handle complex _meta with nested objects', () => {
const source = `export default {
'---': {
type: 'separator'
},
qux: '',
nextra: {
title: 'Nextra',
href: 'https://nextra.site'
}
}`
const keys = extractKeysFromSource(source)
expect(keys).toEqual(['---', 'qux', 'nextra'])
})

it('should return empty array for non-export-default source', () => {
const source = `const foo = { bar: 'baz' }`
const keys = extractKeysFromSource(source)
expect(keys).toEqual([])
})

it('should handle the querypie example from issue #4834', () => {
const source = `export default {
'1140': '11.4.0',
'1130': '11.3.0',
'1120': '11.2.0',
'1110-1112': '11.1.0 ~ 11.1.2',
'1100': '11.0.0',
'1030-1034': '10.3.0 ~ 10.3.4',
'1020-10212': '10.2.0 ~ 10.2.12',
'1010-10111': '10.1.0 ~ 10.1.11',
'1000-1002': '10.0.0 ~ 10.0.2',
'9200-9202': '9.20.0 ~ 9.20.2',
'9190': '9.19.0 ',
'9180-9183': '9.18.0 ~ 9.18.3',
}`
const keys = extractKeysFromSource(source)
// These should be in the EXACT order they appear in source,
// NOT reordered by JavaScript's numeric string key sorting
expect(keys).toEqual([
'1140',
'1130',
'1120',
'1110-1112',
'1100',
'1030-1034',
'1020-10212',
'1010-10111',
'1000-1002',
'9200-9202',
'9190',
'9180-9183'
])
})
})
})
25 changes: 22 additions & 3 deletions packages/nextra/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const DEFAULT_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] as const
const FILENAME = fileURLToPath(import.meta.url)

const LOADER_PATH = path.join(FILENAME, '..', '..', '..', 'loader.cjs')
const META_LOADER_PATH = path.join(FILENAME, '..', 'meta-loader.js')

// Regex to match _meta files
const META_FILE_RE = /_meta\.[jt]sx?$/

const SEP = path.sep === '/' ? '/' : '\\\\'

Expand Down Expand Up @@ -116,7 +120,11 @@ const nextra = (nextraConfig: NextraConfig) => {
(shouldUseConfigTurbopack
? nextConfig.turbopack
: // @ts-expect-error -- Backwards compatibility
nextConfig.experimental?.turbo) ?? {}
nextConfig.experimental?.turbo) ?? {}

const metaLoader = {
loader: META_LOADER_PATH
}

const turbopack = {
...turbopackConfig,
Expand All @@ -136,6 +144,11 @@ const nextra = (nextraConfig: NextraConfig) => {
},
[`**${GET_PAGE_MAP_PATH}`]: {
loaders: [pageMapLoader]
},
// Meta files need to be processed to preserve key order for numeric string keys
// See: https://github.com/shuding/nextra/issues/4834
['**/_meta.{js,jsx,ts,tsx}']: {
loaders: [metaLoader]
}
},
resolveAlias: {
Expand Down Expand Up @@ -164,8 +177,8 @@ const nextra = (nextraConfig: NextraConfig) => {
// To import ESM-only packages with `next dev --turbopack`. Source: https://github.com/vercel/next.js/issues/63318#issuecomment-2079677098
...// Next.js 15
(process.env.TURBOPACK === '1' ||
// Next.js 16
process.env.TURBOPACK === 'auto'
// Next.js 16
process.env.TURBOPACK === 'auto'
? ['shiki', 'ts-morph']
: []),
...(nextConfig.transpilePackages || [])
Expand Down Expand Up @@ -246,6 +259,12 @@ const nextra = (nextraConfig: NextraConfig) => {
use: [options.defaultLoaders.babel, loader]
}
]
},
// Meta files need to be processed to preserve key order for numeric string keys
// See: https://github.com/shuding/nextra/issues/4834
{
test: META_FILE_RE,
use: [options.defaultLoaders.babel, { loader: META_LOADER_PATH }]
}
)

Expand Down
95 changes: 95 additions & 0 deletions packages/nextra/src/server/meta-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { LoaderContext } from 'webpack'

/**
* A webpack loader that transforms _meta files to preserve key order.
*
* JavaScript reorders numeric string keys in objects (e.g., '1140', '1130' become '1130', '1140').
* This loader parses the source to extract the original key order and injects it as `__order__`.
*
* @see https://github.com/shuding/nextra/issues/4834
*/

// Regex to match object property keys in the source
// Handles: 'key', "key", key:, 'key':, "key":
const KEY_REGEX = /(?:^|[,{]\s*)(?:'([^']+)'|"([^"]+)"|(\w[\w-]*))(?:\s*:)/gm
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern doesn't handle template literals or computed property names (e.g., [key]: value or `${version}`: 'title'). If _meta files use these ES6 features, the key extraction will fail silently, leading to incorrect ordering.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable KEY_REGEX.

Suggested change
const KEY_REGEX = /(?:^|[,{]\s*)(?:'([^']+)'|"([^"]+)"|(\w[\w-]*))(?:\s*:)/gm

Copilot uses AI. Check for mistakes.

export function extractKeysFromSource(source: string): string[] {
const keys: string[] = []

// Find the default export object
const exportMatch = source.match(/export\s+default\s*({[\s\S]*})/)
if (!exportMatch) {
return keys
}

const objectContent = exportMatch[1]
if (!objectContent) {
return keys
}

// Track brace depth to only get top-level keys
let depth = 0
let currentPos = 0

for (let i = 0; i < objectContent.length; i++) {
const char = objectContent[i]

if (char === '{') {
depth++
if (depth === 1) {
currentPos = i + 1
}
Comment on lines +32 to +41
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value assigned to currentPos here is unused.

Suggested change
let currentPos = 0
for (let i = 0; i < objectContent.length; i++) {
const char = objectContent[i]
if (char === '{') {
depth++
if (depth === 1) {
currentPos = i + 1
}
for (let i = 0; i < objectContent.length; i++) {
const char = objectContent[i]
if (char === '{') {
depth++
// No need to track currentPos
// if (depth === 1) {
// currentPos = i + 1
// }

Copilot uses AI. Check for mistakes.
} else if (char === '}') {
depth--
} else if (depth === 1) {
// Only process at the first brace level
// Check if we're at the start of a key
const remaining = objectContent.slice(i)

// Match quoted key: 'key' or "key"
const quotedMatch = remaining.match(/^(['"])([^'"]+)\1\s*:/)
if (quotedMatch) {
keys.push(quotedMatch[2]!)
i += quotedMatch[0].length - 1
continue
}

// Match unquoted key: key:
const unquotedMatch = remaining.match(/^([\w][\w-]*)\s*:/)
if (unquotedMatch && !remaining.startsWith('type:') && !remaining.startsWith('items:') && !remaining.startsWith('title:') && !remaining.startsWith('href:') && !remaining.startsWith('display:') && !remaining.startsWith('theme:')) {
// Skip common nested property names
Comment on lines +59 to +60
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded list of property names to skip (type, items, title, href, display, theme) is fragile and could miss nested properties with the same names. Consider using a depth counter or a more robust approach to distinguish between top-level keys and nested object properties. If a top-level key happens to be named "type" or "title", it would incorrectly be skipped.

Suggested change
if (unquotedMatch && !remaining.startsWith('type:') && !remaining.startsWith('items:') && !remaining.startsWith('title:') && !remaining.startsWith('href:') && !remaining.startsWith('display:') && !remaining.startsWith('theme:')) {
// Skip common nested property names
if (unquotedMatch) {

Copilot uses AI. Check for mistakes.
keys.push(unquotedMatch[1]!)
i += unquotedMatch[0].length - 1
continue
Comment on lines +34 to +63
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extraction logic uses a simple character-by-character loop without handling string literals properly. If a key contains escaped quotes or the object value contains nested objects with colons, the depth tracking could become incorrect, potentially extracting wrong keys or missing legitimate top-level keys.

Suggested change
for (let i = 0; i < objectContent.length; i++) {
const char = objectContent[i]
if (char === '{') {
depth++
if (depth === 1) {
currentPos = i + 1
}
} else if (char === '}') {
depth--
} else if (depth === 1) {
// Only process at the first brace level
// Check if we're at the start of a key
const remaining = objectContent.slice(i)
// Match quoted key: 'key' or "key"
const quotedMatch = remaining.match(/^(['"])([^'"]+)\1\s*:/)
if (quotedMatch) {
keys.push(quotedMatch[2]!)
i += quotedMatch[0].length - 1
continue
}
// Match unquoted key: key:
const unquotedMatch = remaining.match(/^([\w][\w-]*)\s*:/)
if (unquotedMatch && !remaining.startsWith('type:') && !remaining.startsWith('items:') && !remaining.startsWith('title:') && !remaining.startsWith('href:') && !remaining.startsWith('display:') && !remaining.startsWith('theme:')) {
// Skip common nested property names
keys.push(unquotedMatch[1]!)
i += unquotedMatch[0].length - 1
continue
let inString: false | "'" | '"' = false;
let escape = false;
for (let i = 0; i < objectContent.length; i++) {
const char = objectContent[i];
if (inString) {
if (escape) {
escape = false;
} else if (char === '\\') {
escape = true;
} else if (char === inString) {
inString = false;
}
continue;
} else {
if (char === "'" || char === '"') {
inString = char;
continue;
}
}
if (char === '{') {
depth++;
if (depth === 1) {
currentPos = i + 1;
}
} else if (char === '}') {
depth--;
} else if (depth === 1) {
// Only process at the first brace level
// Check if we're at the start of a key
const remaining = objectContent.slice(i);
// Match quoted key: 'key' or "key"
const quotedMatch = remaining.match(/^(['"])((?:\\.|[^\\'"])+)\1\s*:/);
if (quotedMatch) {
// Unescape the key
let key = quotedMatch[2].replace(/\\(['"])/g, '$1');
keys.push(key);
i += quotedMatch[0].length - 1;
continue;
}
// Match unquoted key: key:
const unquotedMatch = remaining.match(/^([\w][\w-]*)\s*:/);
if (unquotedMatch && !remaining.startsWith('type:') && !remaining.startsWith('items:') && !remaining.startsWith('title:') && !remaining.startsWith('href:') && !remaining.startsWith('display:') && !remaining.startsWith('theme:')) {
// Skip common nested property names
keys.push(unquotedMatch[1]!);
i += unquotedMatch[0].length - 1;
continue;

Copilot uses AI. Check for mistakes.
}
}
}

return keys
}

export async function metaLoader(
this: LoaderContext<{}>,
source: string
): Promise<string> {
// Extract key order from source
const keys = extractKeysFromSource(source)

if (keys.length === 0) {
// No transformation needed
return source
}

// Inject __order__ property into the exported object
// Transform: export default { ... }
// To: export default { __order__: [...], ... }

const transformed = source.replace(
/export\s+default\s*{/,
`export default { __order__: ${JSON.stringify(keys)},`
)

return transformed
}

export default metaLoader
24 changes: 19 additions & 5 deletions packages/nextra/src/server/page-map/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ function sortFolder(pageMap: PageMapItem[] | Folder | TItem) {
) as ParsedFolder

const meta: Record<string, Record<string, any>> = {}
// Store the original key order from _meta files to handle numeric string keys
// JavaScript reorders numeric string keys (e.g., '1140', '1130' become '1130', '1140')
// See: https://github.com/shuding/nextra/issues/4834
let originalKeyOrder: string[] | undefined

for (const item of folder.children) {
if (
isFolder &&
Expand All @@ -58,7 +63,14 @@ function sortFolder(pageMap: PageMapItem[] | Folder | TItem) {
} else if ('children' in item) {
newChildren.push(normalizePageMap(item))
} else if ('data' in item) {
// Check if __order__ was injected by the meta-loader
if (Array.isArray(item.data.__order__)) {
originalKeyOrder = item.data.__order__
}
for (const [key, titleOrObject] of Object.entries(item.data)) {
// Skip the __order__ helper property
if (key === '__order__') continue

const { data, error } = metaSchema.safeParse(titleOrObject)
if (error) {
throw z.prettifyError(error)
Expand All @@ -77,7 +89,9 @@ function sortFolder(pageMap: PageMapItem[] | Folder | TItem) {
}
}

const metaKeys = Object.keys(meta)
// Use original key order if available (from meta-loader), otherwise fall back to Object.keys
// Object.keys reorders numeric string keys, so we prefer the original order
const metaKeys = originalKeyOrder || Object.keys(meta)
const hasIndexKey = metaKeys.includes('index')

// Normalize items based on files and _meta.json.
Expand Down Expand Up @@ -160,10 +174,10 @@ The field key "${metaKey}" in \`_meta\` file refers to a page that cannot be fou

const result = isFolder
? {
...folder,
title: titlize(folder, {}),
children: itemsWithTitle
}
...folder,
title: titlize(folder, {}),
children: itemsWithTitle
}
: itemsWithTitle
return result
}
Loading