Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2d96275
feat: parseAsTuple (#1036)
I-3B Jul 4, 2025
1fea0c1
feat: add registry package with parseAsTuple item
I-3B Oct 25, 2025
0743e8c
feat: remove parseAsTuple from parsers.test.ts
I-3B Oct 25, 2025
0818109
doc: add README to registry
I-3B Oct 25, 2025
53c8d99
doc(registry): add usage example for parseAsTuple parser
I-3B Oct 26, 2025
ab20790
test(registry): update shadcn to 3.4.2, add vitest for testing, and i…
I-3B Oct 26, 2025
09a0eee
feat(registry): move safe-parse util to registry package, restructur…
I-3B Oct 26, 2025
0bb04a2
chore(registry): reorder dependencies and devDependencies in package.…
I-3B Oct 26, 2025
f76b99d
feat(registry): use nuqs/lib export
I-3B Oct 27, 2025
93640c8
Merge branch 'next' into feat/registry
I-3B Oct 27, 2025
f35ba1b
feat(registry): update docs to use registry package
I-3B Oct 27, 2025
88dad2b
test: add safeParse function export to API test
I-3B Oct 27, 2025
fc7d16f
doc: update parse-as-tuple example to use correct import path
I-3B Oct 29, 2025
0cd611d
Merge branch 'next' into feat/registry
I-3B Nov 23, 2025
98447c2
Merge branch 'next' into feat/registry
I-3B Dec 6, 2025
98841c4
doc(registry): update assemble script to use items from registry package
I-3B Dec 6, 2025
7a096e4
fix(registry): update readUsage function to also lookup usage file fr…
I-3B Dec 7, 2025
821e583
doc(registry): update parseAsTuple usage to show more useful usecases
I-3B Dec 7, 2025
cf615d0
doc(registry): improve comment syntax in parseAsTuple example
I-3B Dec 7, 2025
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
1 change: 1 addition & 0 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"react": "catalog:react19",
"react-dom": "catalog:react19",
"recharts": "3.3.0",
"registry": "workspace:*",
"remark-smartypants": "^3.0.2",
"res": "workspace:*",
"server-only": "^0.0.1",
Expand Down
9 changes: 7 additions & 2 deletions packages/docs/src/registry/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ import {
// Paths
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const packageRoot = resolve(__dirname, '../../')
const itemsDir = resolve(__dirname, './items')
const docsItemsDir = resolve(__dirname, './items')
const registryItemsDir = resolve(packageRoot, 'node_modules/registry/items')
const remoteDir = resolve(__dirname, './remote')
const registryJson = resolve(packageRoot, 'registry.json')

async function loadItems() {
const itemFiles = await Array.fromAsync(glob(`${itemsDir}/*.json`))
const [localFiles, registryFiles] = await Promise.all([
Array.fromAsync(glob(`${docsItemsDir}/*.json`)),
Array.fromAsync(glob(`${registryItemsDir}/*.json`))
])
const itemFiles = [...localFiles, ...registryFiles]
return await Promise.all(
itemFiles.map(async filePath => {
const item = await loadItem(filePath)
Expand Down
18 changes: 13 additions & 5 deletions packages/docs/src/registry/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,20 @@ export async function readRegistryItem(name: string) {
}

export async function readUsage(name: string) {
try {
const fileName = resolve(process.cwd(), `src/registry/items/${name}.md`)
return await readFile(fileName, 'utf-8')
} catch {
return null
const paths = [
resolve(process.cwd(), `src/registry/items/${name}.md`),
resolve(process.cwd(), `node_modules/registry/items/${name}.md`)
]

for (const filePath of paths) {
try {
return await readFile(filePath, 'utf-8')
} catch {
continue
}
}

return null
}

export const registryItemCategories = [
Expand Down
8 changes: 7 additions & 1 deletion packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"adapters/react-router/v7.d.ts",
"adapters/tanstack-router.d.ts",
"adapters/custom.d.ts",
"adapters/testing.d.ts"
"adapters/testing.d.ts",
"lib/index.d.ts"
],
"type": "module",
"sideEffects": false,
Expand Down Expand Up @@ -125,6 +126,11 @@
"types": "./dist/adapters/testing.d.ts",
"import": "./dist/adapters/testing.js",
"default": "./dist/adapters/testing.js"
},
"./lib": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js",
"default": "./dist/lib.js"
}
},
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions packages/nuqs/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const exports = `
"NuqsTestingAdapter": "function",
"withNuqsTestingAdapter": "function",
},
"./lib": {
"safeParse": "function",
},
"./server": {
"createLoader": "function",
"createMultiParser": "function",
Expand Down
1 change: 1 addition & 0 deletions packages/nuqs/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './safe-parse'
3 changes: 2 additions & 1 deletion packages/nuqs/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const entrypoints = {
'adapters/react-router/v7': 'src/adapters/react-router/v7.ts',
'adapters/tanstack-router': 'src/adapters/tanstack-router.ts',
'adapters/custom': 'src/adapters/custom.ts',
'adapters/testing': 'src/adapters/testing.ts'
'adapters/testing': 'src/adapters/testing.ts',
lib: 'src/lib/index.ts'
},
server: {
server: 'src/index.server.ts',
Expand Down
1 change: 1 addition & 0 deletions packages/registry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/r/
30 changes: 30 additions & 0 deletions packages/registry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# registry

A shadcn/ui compatible registry for community parsers and utilities for the [nuqs](https://nuqs.dev) library.

## Development

```bash
# Install dependencies
pnpm install

# Build the registry
cd ../docs && pnpm build:registry
# Run the docs server
cd ../docs && pnpm dev

```

Usage example in any npm package:

```bash
pnpm dlx shadcn@latest add http://localhost:3000/r/parse-as-tuple.json
```

## Learn More

To learn more about nuqs and shadcn/ui registries, take a look at the following resources:

- [nuqs Documentation](https://nuqs.dev) - learn about nuqs features and API
- [shadcn/ui Registry Documentation](https://ui.shadcn.com/docs/registry) - learn about shadcn/ui registries
- [nuqs GitHub Repository](https://github.com/47ng/nuqs) - source code and issues
14 changes: 14 additions & 0 deletions packages/registry/items/parse-as-tuple.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "parse-as-tuple",
"type": "registry:item",
"title": "Tuples",
"description": "Parse query string as a tuple of values.",
"dependencies": ["nuqs"],
"files": [
{
"path": "node_modules/registry/items/parse-as-tuple.ts",
"type": "registry:file",
"target": "~/src/lib/parsers/parse-as-tuple.ts"
}
]
}
12 changes: 12 additions & 0 deletions packages/registry/items/parse-as-tuple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
The `parseAsTuple` parser allows you to parse fixed-length tuples with **any type** for each position.

```ts
import { parseAsTuple } from '@/lib/parsers/parse-as-tuple'
import { parseAsString, parseAsStringLiteral } from 'nuqs'

// Sorting tuple [key: string, direction: 'asc' | 'desc']
parseAsTuple([parseAsString, parseAsStringLiteral(['asc', 'desc'])])

// Optionally, customise the separator
parseAsTuple([parseAsString, parseAsString], ';')
```
41 changes: 41 additions & 0 deletions packages/registry/items/parse-as-tuple.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseAsBoolean, parseAsInteger, parseAsString } from 'nuqs'
import {
isParserBijective,
testParseThenSerialize,
testSerializeThenParse
} from 'nuqs/testing'
import { describe, expect, it } from 'vitest'
import { parseAsTuple } from './parse-as-tuple'

describe('parseAsTuple', () => {
it('parses and serializes tuples correctly', () => {
const parser = parseAsTuple([parseAsInteger, parseAsString, parseAsBoolean])
expect(parser.parse('1,a,false,will-ignore')).toStrictEqual([1, 'a', false])
expect(parser.parse('not-a-number,a,true')).toBeNull()
expect(parser.parse('1,a')).toBeNull()
// @ts-expect-error - Tuple length is less than 2
expect(() => parseAsTuple([parseAsInteger])).toThrow()
expect(parser.serialize([1, 'a', true])).toBe('1,a,true')
// @ts-expect-error - Tuple length mismatch
expect(() => parser.serialize([1, 'a'])).toThrow()
expect(testParseThenSerialize(parser, '1,a,true')).toBe(true)
expect(testSerializeThenParse(parser, [1, 'a', true] as const)).toBe(true)
expect(isParserBijective(parser, '1,a,true', [1, 'a', true] as const)).toBe(
true
)
expect(() =>
isParserBijective(parser, 'not-a-tuple', [1, 'a', true] as const)
).toThrow()
})

it('equality comparison works correctly', () => {
const eq = parseAsTuple([parseAsInteger, parseAsBoolean]).eq!
expect(eq([1, true], [1, true])).toBe(true)
expect(eq([1, true], [1, false])).toBe(false)
expect(eq([1, true], [2, true])).toBe(false)
// @ts-expect-error - Tuple length mismatch
expect(eq([1, true], [1])).toBe(false)
// @ts-expect-error - Tuple length mismatch
expect(eq([1], [1])).toBe(false)
})
})
74 changes: 74 additions & 0 deletions packages/registry/items/parse-as-tuple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createParser, type SingleParserBuilder } from 'nuqs'
import { safeParse } from 'nuqs/lib'

type ParserTuple<T extends readonly unknown[]> = {
[K in keyof T]: SingleParserBuilder<T[K]>
} & { length: 2 | 3 | 4 | 5 | 6 | 7 | 8 }

/**
* Parse a comma-separated tuple with type-safe positions.
* Items are URI-encoded for safety, so they may not look nice in the URL.
* allowed tuple length is 2-8.
*
* @param itemParsers Tuple of parsers for each position in the tuple
* @param separator The character to use to separate items (default ',')
*/
export function parseAsTuple<T extends any[]>(
itemParsers: ParserTuple<T>,
separator = ','
): SingleParserBuilder<T> {
const encodedSeparator = encodeURIComponent(separator)
if (itemParsers.length < 2 || itemParsers.length > 8) {
throw new Error(
`Tuple length must be between 2 and 8, got ${itemParsers.length}`
)
}
return createParser<T>({
parse: query => {
if (query === '') {
return null
}
const parts = query.split(separator)
if (parts.length < itemParsers.length) {
return null
}
// iterating by parsers instead of parts, any additional parts are ignored.
const result = itemParsers.map(
(parser, index) =>
safeParse(
parser.parse,
parts[index]!.replaceAll(encodedSeparator, separator),
`[${index}]`
) as T[number] | null
)
return result.some(x => x === null) ? null : (result as T)
},
serialize: (values: T) => {
if (values.length !== itemParsers.length) {
throw new Error(
`Tuple length mismatch: expected ${itemParsers.length}, got ${values.length}`
)
}
return values
.map((value, index) => {
const parser = itemParsers[index]!
const str = parser.serialize ? parser.serialize(value) : String(value)
return str.replaceAll(separator, encodedSeparator)
})
.join(separator)
},
eq(a: T, b: T) {
if (a === b) {
return true
}
if (a.length !== b.length || a.length !== itemParsers.length) {
return false
}
return a.every((value, index) => {
const parser = itemParsers[index]!
const itemEq = parser.eq ?? ((x, y) => x === y)
return itemEq(value, b[index])
})
}
})
}
24 changes: 24 additions & 0 deletions packages/registry/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "registry",
"description": "Shadcn CLI registry for community parsers & utilities",
"type": "module",
"exports": {
"./items/*": "./items/*"
},
"files": [
"items"
],
"scripts": {
"test": "pnpm run --stream '/^test:/'",
"test:unit": "vitest run --typecheck"
},
"dependencies": {
"next": "16.0.7",
"nuqs": "workspace:*",
"shadcn": "^3.4.2"
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^4.0.1"
}
}
36 changes: 36 additions & 0 deletions packages/registry/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"jsx": "react",
// Type checking
"strict": true,
"noUncheckedIndexedAccess": true,
"alwaysStrict": false, // Don't emit "use strict" to avoid conflicts with "use client"
// Modules
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
// Language & Environment
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
// Emit
"noEmit": true,
"declaration": true,
"declarationMap": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",

"downlevelIteration": true,
// Interop
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
// Misc
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
11 changes: 11 additions & 0 deletions packages/registry/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig, type ViteUserConfig } from 'vitest/config'

const config: ViteUserConfig = defineConfig({
test: {
typecheck: {
tsconfig: './tsconfig.json'
}
}
})

export default config
Loading