Skip to content
Merged
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
65 changes: 65 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# CLAUDE.md

ESM-only (`type: module`) Feathers utility library. All relative imports use the
`.js` extension.

## Entrypoints

Seven barrels, each a package export and a tsdown build entry:
`.` · `./hooks` · `./utils` · `./predicates` · `./resolvers` · `./transformers` · `./guards`
→ `src/<category>/index.ts`. A new public feature must be re-exported from its
category barrel.

## Feature folder convention

Each public feature is its own **kebab-case** folder under its category, holding
sibling files named `<name>.<kind>.*` where `kind` ∈
`util|hook|predicate|resolver|transformer|guard`:

- `<name>.<kind>.ts` — implementation; the main export is **camelCase**.
- `<name>.<kind>.test.ts` — runtime tests (vitest).
- `<name>.<kind>.test-d.ts` — type-level tests (optional).
- `<name>.<kind>.md` — docs frontmatter (see below).

## Shared internals

- `src/common/` — shared helpers, exported via `src/common/index.js`, imported as
`../../common/index.js`. Small ones use in-source tests (`if (import.meta.vitest)`),
stripped from the build.
- `src/internal.utils.ts` — internal helpers/types, not public API.

## Docs (VitePress)

Auto-discovered from `src/**/*.md`; the prose body is generated from the sibling
`.ts` file's JSDoc — keep `@example`/`@see` authoritative there. Frontmatter:

```yaml
---
title: stringifyParams # camelCase export name
category: utils
see: # optional cross-links
- hooks/cache
- utils/gateParams
---
```

- **`see:` references use `category/camelCaseName`** — the camelCase export name,
NOT the kebab folder/slug (`utils/gateParams`, never `utils/gate-params`).

## Tests

- `vitest`, globals on. Runtime `*.test.ts`; type-level `*.test-d.ts` (typecheck
enabled in vitest). Coverage thresholds 80%.
- `test/index.test.ts` asserts the **exact** public export surface per entrypoint
— update its lists when adding/removing an export.

## Lint / format

- ESLint (`@feathers-community/eslint-config`) has **Prettier built in as a rule** —
there is no separate prettier config; run `eslint --fix` to format.
- **No `node:` protocol imports in `src/**/*.ts`** (lint error; allowed in tests).

## Commands

- `npm test` — lint + typecheck + coverage (the full gate).
- `npm run lint` · `npm run typecheck` · `npm run test:unit` · `npm run build` (tsdown).
101 changes: 101 additions & 0 deletions docs/.vitepress/export-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import zlib from 'node:zlib'
import { build } from 'esbuild'
import type { Utility, UtilityCategory } from './utilities'

export type BundleSize = {
/** Minified byte size of the export's own code (deps excluded). */
minified: number
/** Gzipped byte size (level 9) of the minified output. */
gzip: number
}

const distDir = path.resolve(import.meta.dirname, '../../dist')

// Memoized across the many discoverUtilities() calls (config load, each data
// loader, each *.paths.ts, markdownTransform). Invalidated when dist changes.
let cache: Map<string, BundleSize> | null = null
let cacheKey = ''

const keyOf = (category: UtilityCategory, name: string) => `${category}/${name}`

/** Newest mtime across the dist barrels + chunks, used as a cache key. */
async function distSignature(): Promise<string | null> {
let files: string[]
try {
files = (await fs.readdir(distDir)).filter((f) => f.endsWith('.mjs'))
} catch {
return null // dist/ missing → measurement unavailable
}
if (!files.length) return null

let newest = 0
for (const file of files) {
const { mtimeMs } = await fs.stat(path.join(distDir, file))
if (mtimeMs > newest) newest = mtimeMs
}
return `${newest}:${files.length}`
}

/**
* Measures each utility's own-code bundle size (min + gzip) by re-bundling a
* single named export from its published dist barrel. npm dependencies are
* marked external (`packages: 'external'`), so only the util's own transitive
* lib code is counted — matching tsdown's externals and VueUse's semantics.
*/
async function computeSizes(
utilities: Utility[],
): Promise<Map<string, BundleSize>> {
const result = new Map<string, BundleSize>()

await Promise.all(
utilities.map(async (utility) => {
const barrel = path.join(distDir, `${utility.category}.mjs`)
try {
const out = await build({
stdin: {
contents: `export { ${utility.name} } from ${JSON.stringify(barrel)}`,
resolveDir: distDir,
loader: 'js',
},
bundle: true,
format: 'esm',
platform: 'neutral',
minify: true,
treeShaking: true,
packages: 'external',
write: false,
legalComments: 'none',
})
const code = out.outputFiles[0].contents
result.set(keyOf(utility.category, utility.name), {
minified: code.byteLength,
gzip: zlib.gzipSync(code, { level: 9 }).byteLength,
})
} catch {
// Export not found in barrel / bundling failed → skip this one.
}
}),
)

return result
}

/**
* Attaches `bundleSize` to each utility in-place. No-op (leaves `bundleSize`
* undefined) when `dist/` is missing, so `docs:dev` works without a prior build.
*/
export async function attachExportSizes(utilities: Utility[]): Promise<void> {
const signature = await distSignature()
if (!signature) return

if (!cache || cacheKey !== signature) {
cache = await computeSizes(utilities)
cacheKey = signature
}

for (const utility of utilities) {
utility.bundleSize = cache.get(keyOf(utility.category, utility.name))
}
}
74 changes: 50 additions & 24 deletions docs/.vitepress/plugins/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,63 @@ const resolveLinks = (text: string, utilities: Utility[]) =>
},
)

const formatBytes = (bytes: number) => `${(bytes / 1024).toFixed(2)} kB`

export default (utility: Utility, utilities: Utility[]) => {
const code = [
`# ${utility.title}`,
`<Chip label="${utility.category}" class="mt-2 mr-2" /> [Source Code](${utility.sourceUrl}) | [Documentation](${utility.docsUrl})`,
]
const code = [`# ${utility.title}`]

// see also
// Meta grid (VueUse-style): fixed label column + value column. Rendered as a
// raw HTML block, so values use HTML (<a>/<code>) — markdown isn't parsed
// inside HTML blocks; only Vue components like <Chip> survive.
;(() => {
const see = utility.frontmatter?.see ?? []
const rows: [label: string, value: string][] = []

rows.push([
'Category',
`<Chip label="${utility.category}" class="mr-2" /> <a href="${utility.sourceUrl}" target="_blank" rel="noreferrer">Source Code</a> | <a href="${utility.docsUrl}" target="_blank" rel="noreferrer">Documentation</a>`,
])

if (utility.bundleSize) {
rows.push([
'Export size',
`min ${formatBytes(utility.bundleSize.minified)} · gzip ${formatBytes(utility.bundleSize.gzip)}`,
])
}

if (utility.aliases?.length) {
rows.push([
'Aliases',
utility.aliases.map((a) => `<code>${a}</code>`).join(', '),
])
}

const see: string[] = utility.frontmatter?.see ?? []
if (see.length > 0) {
const seeAlso = `_See also_: ${see
.map((x: string) => {
const found = utilities.find((u) => u.name === x)
if (found) {
return `[\`${x}\`](${found.path})`
}
const parts = x.split('/')
return `[\`${x}\`](/${parts.map(kebabCase).join('/')}${parts.length === 1 ? '/' : '.html'})`
})
.join(' ')}`

code.push(seeAlso)
rows.push([
'See also',
see
.map((x) => {
const found = utilities.find((u) => u.name === x)
const href = found
? found.path
: `/${x.split('/').map(kebabCase).join('/')}${x.includes('/') ? '.html' : '/'}`
return `<a href="${href}"><code>${x}</code></a>`
})
.join(' '),
])
}
})()

if (utility.aliases?.length) {
code.push(
`_Aliases_: ${utility.aliases.map((a: string) => `\`${a}\``).join(', ')}`,
)
}
const grid = [
`<div class="grid grid-cols-[100px_auto] gap-x-4 gap-y-2 items-center mt-4 mb-8 text-sm">`,
...rows.flatMap(([label, value]) => [
`<div class="opacity-60">${label}</div>`,
`<div>${value}</div>`,
]),
`</div>`,
].join('\n')

code.push(grid)
})()

code.push(`${resolveLinks(utility.description, utilities)}

Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Node } from 'typescript'
import ts from 'typescript'
import prettier from 'prettier'
import path from 'node:path'
import { attachExportSizes, type BundleSize } from './export-size'

export const utilityCategories = [
'hooks',
Expand Down Expand Up @@ -37,6 +38,7 @@ export type Utility = {
transformers?: boolean
predicates?: boolean
dts: string
bundleSize?: BundleSize
examples?: string[]
aliases?: string[]
args?: {
Expand Down Expand Up @@ -283,5 +285,7 @@ export async function discoverUtilities() {

utilitiesList.sort((a, b) => a.title.localeCompare(b.title))

await attachExportSizes(utilitiesList)

return utilitiesList
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"scripts": {
"build": "tsdown",
"docs:dev": "vitepress dev docs --port 5177",
"docs:build": "vitepress build docs",
"docs:build": "pnpm build && vitepress build docs",
"docs:preview": "vitepress preview docs --port 4177",
"version": "pnpm build",
"lint": "eslint .",
Expand Down Expand Up @@ -95,7 +95,8 @@
"dequal": "^2.0.3",
"fast-copy": "^4.0.3",
"lodash": "^4.18.1",
"neotraverse": "^0.6.18"
"neotraverse": "^0.6.18",
"safe-stable-stringify": "^2.5.0"
},
"devDependencies": {
"@feathers-community/eslint-config": "^0.2.0",
Expand All @@ -115,6 +116,7 @@
"@types/node": "^25.9.2",
"@vitest/coverage-v8": "^4.1.8",
"dedent": "^1.7.2",
"esbuild": "^0.28.1",
"eslint": "^10.4.1",
"gray-matter": "^4.0.3",
"lru-cache": "^11.5.1",
Expand Down
Loading
Loading