snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
// @ts-nocheck
import fs from 'fs'
import path from 'node:path'
import { globSync } from 'node:fs'
import { execSync, execFileSync } from 'node:child_process'
import { tmpdir } from 'node:os'
const rootDir = path.join(import.meta.dirname, '..')
const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
// Resolve GitHub usernames from commit author emails
const usernameCache = {}
async function resolveUsername(email) {
if (!ghToken || !email) return null
if (usernameCache[email] !== undefined) return usernameCache[email]
try {
const res = await fetch(`https://api.github.com/search/users?q=${email}`, {
headers: { Authorization: `token ${ghToken}` },
})
const data = await res.json()
const login = data?.items?.[0]?.login || null
usernameCache[email] = login
return login
} catch {
usernameCache[email] = null
return null
}
}
// Resolve author from a PR number via GitHub API
const prAuthorCache = {}
async function resolveAuthorForPR(prNumber) {
if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber]
if (!ghToken) {
prAuthorCache[prNumber] = null
return null
}
try {
const res = await fetch(
`https://api.github.com/repos/TanStack/query/pulls/${prNumber}`,
{ headers: { Authorization: `token ${ghToken}` } },
)
const data = await res.json()
const login = data?.user?.login || null
prAuthorCache[prNumber] = login
return login
} catch {
prAuthorCache[prNumber] = null
return null
}
}
// Get the previous release commit to diff against.
// This script runs right after the "ci: Version Packages" PR is merged,
// so HEAD is the release commit.
const releaseLogs = execSync(
'git log --oneline --grep="^ci: Version Packages" --grep="^ci: changeset release" --format=%H',
)
.toString()
.trim()
.split('\n')
.filter(Boolean)
const currentRelease = releaseLogs[0] || 'HEAD'
const previousRelease = releaseLogs[1]
// Find packages that were actually bumped by comparing versions
const packagesDir = path.join(rootDir, 'packages')
const allPkgJsonPaths = globSync('*/package.json', { cwd: packagesDir })
const bumpedPackages = []
for (const relPath of allPkgJsonPaths) {
const fullPath = path.join(packagesDir, relPath)
const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8'))
if (currentPkg.private) continue
// Get the version from the previous release commit
if (previousRelease) {
try {
const prevContent = execFileSync(
'git',
['show', `${previousRelease}:packages/${relPath}`],
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
)
const prevPkg = JSON.parse(prevContent)
if (prevPkg.version !== currentPkg.version) {
bumpedPackages.push({
name: currentPkg.name,
version: currentPkg.version,
prevVersion: prevPkg.version,
dir: path.dirname(relPath),
})
}
} catch {
// Package didn't exist in previous release — it's new
bumpedPackages.push({
name: currentPkg.name,
version: currentPkg.version,
prevVersion: null,
dir: path.dirname(relPath),
})
}
} else {
// No previous release — include all non-private packages
bumpedPackages.push({
name: currentPkg.name,
version: currentPkg.version,
prevVersion: null,
dir: path.dirname(relPath),
})
}
}
bumpedPackages.sort((a, b) => a.name.localeCompare(b.name))
// Build changelog from git log between releases (conventional commits)
const rangeFrom = previousRelease || `${currentRelease}~1`
const rawLog = execSync(
`git log ${rangeFrom}..${currentRelease} --pretty=format:"%h %ae %s" --no-merges`,
{ encoding: 'utf-8' },
).trim()
const typeOrder = [
'breaking',
'feat',
'fix',
'perf',
'refactor',
'docs',
'chore',
'test',
'ci',
]
const typeLabels = {
breaking: '⚠️ Breaking Changes',
feat: 'Features',
fix: 'Fix',
perf: 'Performance',
refactor: 'Refactor',
docs: 'Documentation',
chore: 'Chore',
test: 'Tests',
ci: 'CI',
}
const typeIndex = (t) => {
const i = typeOrder.indexOf(t)
return i === -1 ? 99 : i
}
const groups = {}
const commits = rawLog ? rawLog.split('\n') : []
for (const line of commits) {
const match = line.match(/^(\w+)\s+(\S+)\s+(.*)$/)
if (!match) continue
const [, hash, email, subject] = match
// Skip release commits
if (
subject.startsWith('ci: Version Packages') ||
subject.startsWith('ci: changeset release')
)
continue
// Parse conventional commit: type(scope)!: message
const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/)
const type = conventionalMatch ? conventionalMatch[1] : 'other'
const isBreaking = conventionalMatch ? !!conventionalMatch[3] : false
const scope = conventionalMatch ? conventionalMatch[2] || '' : ''
const message = conventionalMatch ? conventionalMatch[4] : subject
// Only include user-facing change types
if (!['feat', 'fix', 'perf', 'refactor', 'build', 'chore'].includes(type))
continue
// Extract PR number if present
const prMatch = message.match(/\(#(\d+)\)/)
const prNumber = prMatch ? prMatch[1] : null
const bucket = isBreaking ? 'breaking' : type
if (!groups[bucket]) groups[bucket] = []
groups[bucket].push({ hash, email, scope, message, prNumber })
}
// Build markdown grouped by conventional commit type
const sortedTypes = Object.keys(groups).sort(
(a, b) => typeIndex(a) - typeIndex(b),
)
let changelogMd = ''
for (const type of sortedTypes) {
const label = typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1)
changelogMd += `### ${label}\n\n`
for (const commit of groups[type]) {
const scopePrefix = commit.scope ? `${commit.scope}: ` : ''
const cleanMessage = commit.message.replace(/\s*\(#\d+\)/, '')
const prRef = commit.prNumber ? ` (#${commit.prNumber})` : ''
const username = commit.prNumber
? await resolveAuthorForPR(commit.prNumber)
: await resolveUsername(commit.email)
const authorSuffix = username ? ` by @${username}` : ''
changelogMd += `- ${scopePrefix}${cleanMessage}${prRef} (${commit.hash})${authorSuffix}\n`
}
changelogMd += '\n'
}
if (!changelogMd.trim()) {
changelogMd = '- No changelog entries\n\n'
}
const now = new Date()
const date = now.toISOString().slice(0, 10)
const time = now.toISOString().slice(11, 16).replace(':', '')
const tagName = `release-${date}-${time}`
const titleDate = `${date} ${now.toISOString().slice(11, 16)}`
const isPrerelease = process.argv.includes('--prerelease')
const isLatest = process.argv.includes('--latest')
const body = `Release ${titleDate}
## Changes
${changelogMd}
## Packages
${bumpedPackages.map((p) => `- ${p.name}@${p.version}`).join('\n')}
`
// Create the release
// Check if tag already exists — if so, try to create the release for it
// (handles retries where the tag was pushed but release creation failed)
let tagExists = false
try {
execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' })
tagExists = true
} catch {
// Tag doesn't exist yet
}
if (!tagExists) {
execSync(`git tag -a -m "${tagName}" ${tagName}`)
execSync('git push --tags')
}
const prereleaseFlag = isPrerelease ? '--prerelease' : ''
const latestFlag = isLatest ? ' --latest' : ''
const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`)
fs.writeFileSync(tmpFile, body)
try {
execSync(
`gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`,
{ stdio: 'inherit' },
)
console.info(`GitHub release ${tagName} created.`)
} catch (err) {
// Clean up the tag if we created it but release failed
if (!tagExists) {
console.info(`Release creation failed, cleaning up tag ${tagName}...`)
try {
execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' })
execSync(`git tag -d ${tagName}`, { stdio: 'ignore' })
} catch {
// Best effort cleanup
}
}
throw err
} finally {
fs.unlinkSync(tmpFile)
}

350
scripts/generate-docs.ts Normal file
View File

@@ -0,0 +1,350 @@
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const require = createRequire(import.meta.url)
const typedocConfigPackageJson =
require.resolve('@tanstack/typedoc-config/package.json')
const typedocConfigDir = dirname(typedocConfigPackageJson)
const typedocConfigRequire = createRequire(typedocConfigPackageJson)
const TypeDoc = await import(typedocConfigRequire.resolve('typedoc'))
type PackageReferenceDocsConfig = {
entryPoints: Array<string>
tsconfig: string
outputDir: string
exclude?: Array<string>
excludeExternals?: boolean
simplifyLitQueriesControllerTypes?: boolean
trimGeneratedMarkdown?: boolean
// Maps a generated page's path (relative to outputDir, no extension) to the flat
// `docs/framework/<framework>/reference/<name>.md` URLs it replaced, so old links/bookmarks redirect
// instead of falling through to the framework docs index. See https://github.com/TanStack/query/issues/11371
redirectFrom?: Record<string, Array<string>>
}
type TypeDocReflectionWithSignatures = {
name: string
children?: Array<TypeDocReflectionWithSignatures>
signatures?: Array<{
typeParameters?: Array<{
name: string
default?: unknown
}>
}>
}
function simplifyLitQueriesControllerTypes(
project: TypeDocReflectionWithSignatures,
) {
const stack: Array<TypeDocReflectionWithSignatures> = [project]
for (const reflection of stack) {
stack.push(...(reflection.children ?? []))
if (reflection.name !== 'createQueriesController') {
continue
}
for (const signature of reflection.signatures ?? []) {
const combinedResult = signature.typeParameters?.find(
(typeParameter) => typeParameter.name === 'TCombinedResult',
)
if (!combinedResult?.default) {
continue
}
const queryOptionsType = TypeDoc.ReferenceType.createBrokenReference(
'TQueryOptions',
project,
undefined,
)
queryOptionsType.refersToTypeParameter = true
// CreateQueriesResults is internal; render it as plain text, not a link.
const queriesResultsType = TypeDoc.ReferenceType.createBrokenReference(
'CreateQueriesResults',
project,
undefined,
)
queriesResultsType.typeArguments = [queryOptionsType]
combinedResult.default = queriesResultsType
}
}
}
async function trimTrailingWhitespaceInMarkdown(outputDir: string) {
const entries = await readdir(outputDir, { withFileTypes: true })
await Promise.all(
entries.map(async (entry) => {
const path = resolve(outputDir, entry.name)
if (entry.isDirectory()) {
await trimTrailingWhitespaceInMarkdown(path)
return
}
if (!entry.isFile() || !path.endsWith('.md')) {
return
}
const markdown = await readFile(path, 'utf8')
const trimmed = markdown.replace(/[ \t]+$/gm, '')
if (trimmed !== markdown) {
await writeFile(path, trimmed)
}
}),
)
}
async function addRedirectFromToFileFrontmatter(
filePath: string,
fromPaths: Array<string>,
) {
const markdown = await readFile(filePath, 'utf8')
const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n/)
if (!frontmatterMatch) {
throw new Error(`Expected frontmatter in ${filePath}`)
}
const redirectLines = fromPaths
.map((fromPath) => ` - ${fromPath}`)
.join('\n')
const updatedFrontmatter = `---\n${frontmatterMatch[1]}\nredirect_from:\n${redirectLines}\n---\n`
await writeFile(
filePath,
updatedFrontmatter + markdown.slice(frontmatterMatch[0].length),
)
}
async function addRedirectFromToFrontmatter(
outputDir: string,
redirectFrom: Record<string, Array<string>>,
) {
for (const [pagePath, fromPaths] of Object.entries(redirectFrom)) {
await addRedirectFromToFileFrontmatter(
resolve(outputDir, `${pagePath}.md`),
fromPaths,
)
}
}
async function generatePackageReferenceDocs(pkg: PackageReferenceDocsConfig) {
const outputDir = pkg.outputDir
await rm(outputDir, { recursive: true, force: true })
await mkdir(outputDir, { recursive: true })
const app = await TypeDoc.Application.bootstrapWithPlugins({
plugin: [
'typedoc-plugin-markdown',
'typedoc-plugin-frontmatter',
resolve(typedocConfigDir, './src/typedoc-custom-settings.js'),
],
hideGenerator: true,
readme: 'none',
entryFileName: 'index',
hideBreadcrumbs: true,
hidePageHeader: true,
hidePageTitle: true,
useCodeBlocks: true,
excludePrivate: true,
excludeInternal: true,
excludeExternals: pkg.excludeExternals,
sourceLinkTemplate:
'https://github.com/TanStack/query/blob/{gitRevision}/{path}#L{line}',
gitRevision: 'main',
entryPoints: pkg.entryPoints,
tsconfig: pkg.tsconfig,
...(pkg.exclude && { exclude: pkg.exclude }),
out: outputDir,
})
const project = await app.convert()
if (project) {
if (pkg.simplifyLitQueriesControllerTypes) {
simplifyLitQueriesControllerTypes(project)
}
await app.generateOutputs(project)
if (pkg.trimGeneratedMarkdown) {
await trimTrailingWhitespaceInMarkdown(outputDir)
}
if (pkg.redirectFrom) {
await addRedirectFromToFrontmatter(outputDir, pkg.redirectFrom)
}
}
}
const packages: Array<PackageReferenceDocsConfig> = [
{
entryPoints: [
resolve(__dirname, '../packages/angular-query-experimental/src/index.ts'),
],
tsconfig: resolve(
__dirname,
'../packages/angular-query-experimental/tsconfig.json',
),
outputDir: resolve(__dirname, '../docs/framework/angular/reference'),
},
{
entryPoints: [resolve(__dirname, '../packages/svelte-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/svelte-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/svelte/reference'),
},
{
entryPoints: [resolve(__dirname, '../packages/solid-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/solid-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/solid/reference'),
redirectFrom: {
'functions/infiniteQueryOptions': [
'framework/solid/reference/infiniteQueryOptions',
],
'functions/mutationOptions': [
'framework/solid/reference/mutationOptions',
],
'functions/queryOptions': ['framework/solid/reference/queryOptions'],
'functions/useInfiniteQuery': [
'framework/solid/reference/useInfiniteQuery',
],
'functions/useIsFetching': ['framework/solid/reference/useIsFetching'],
'functions/useIsMutating': ['framework/solid/reference/useIsMutating'],
'functions/useMutation': ['framework/solid/reference/useMutation'],
'functions/useMutationState': [
'framework/solid/reference/useMutationState',
],
'functions/useQueries': ['framework/solid/reference/useQueries'],
'functions/useQuery': ['framework/solid/reference/useQuery'],
},
},
{
entryPoints: [resolve(__dirname, '../packages/vue-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/vue-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/vue/reference'),
},
{
entryPoints: [resolve(__dirname, '../packages/react-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/react-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/react/reference'),
redirectFrom: {
'functions/infiniteQueryOptions': [
'framework/react/reference/infiniteQueryOptions',
],
'functions/mutationOptions': [
'framework/react/reference/mutationOptions',
],
'functions/QueryClientProvider': [
'framework/react/reference/QueryClientProvider',
],
'functions/QueryErrorResetBoundary': [
'framework/react/reference/QueryErrorResetBoundary',
],
'functions/queryOptions': ['framework/react/reference/queryOptions'],
'functions/useInfiniteQuery': [
'framework/react/reference/useInfiniteQuery',
],
'functions/useIsFetching': ['framework/react/reference/useIsFetching'],
'functions/useIsMutating': ['framework/react/reference/useIsMutating'],
'functions/useMutation': ['framework/react/reference/useMutation'],
'functions/useMutationState': [
'framework/react/reference/useMutationState',
],
'functions/usePrefetchInfiniteQuery': [
'framework/react/reference/usePrefetchInfiniteQuery',
],
'functions/usePrefetchQuery': [
'framework/react/reference/usePrefetchQuery',
],
'functions/useQueries': ['framework/react/reference/useQueries'],
'functions/useQuery': ['framework/react/reference/useQuery'],
'functions/useQueryClient': ['framework/react/reference/useQueryClient'],
'functions/useQueryErrorResetBoundary': [
'framework/react/reference/useQueryErrorResetBoundary',
],
'functions/useSuspenseInfiniteQuery': [
'framework/react/reference/useSuspenseInfiniteQuery',
],
'functions/useSuspenseQueries': [
'framework/react/reference/useSuspenseQueries',
],
'functions/useSuspenseQuery': [
'framework/react/reference/useSuspenseQuery',
],
// Redirects from the legacy hand-written docs/reference/*.md pages, removed in favor of
// this generated reference.
'classes/QueryClient': [
'reference/QueryClient',
'framework/react/reference/QueryClient',
],
'classes/QueryCache': [
'reference/QueryCache',
'framework/react/reference/QueryCache',
],
'classes/MutationCache': [
'reference/MutationCache',
'framework/react/reference/MutationCache',
],
'classes/QueryObserver': [
'reference/QueryObserver',
'framework/react/reference/QueryObserver',
],
'classes/InfiniteQueryObserver': [
'reference/InfiniteQueryObserver',
'framework/react/reference/InfiniteQueryObserver',
],
'classes/QueriesObserver': [
'reference/QueriesObserver',
'framework/react/reference/QueriesObserver',
],
// focusManager/onlineManager/timeoutManager are class instances, not object literals, so
// TypeDoc can't inline their methods onto the `variables/*` instance page — the method docs
// that the legacy pages covered now live on the `interfaces/*` page for the class itself.
'interfaces/FocusManager': [
'reference/focusManager',
'framework/react/reference/focusManager',
],
'interfaces/OnlineManager': [
'reference/onlineManager',
'framework/react/reference/onlineManager',
],
'interfaces/TimeoutManager': ['reference/timeoutManager'],
'variables/notifyManager': [
'reference/notifyManager',
'framework/react/reference/notifyManager',
],
'variables/environmentManager': ['reference/environmentManager'],
'functions/experimental_streamedQuery': ['reference/streamedQuery'],
},
},
{
entryPoints: [resolve(__dirname, '../packages/preact-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/preact-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/preact/reference'),
},
{
entryPoints: [resolve(__dirname, '../packages/lit-query/src/index.ts')],
tsconfig: resolve(__dirname, '../packages/lit-query/tsconfig.json'),
outputDir: resolve(__dirname, '../docs/framework/lit/reference'),
excludeExternals: true,
simplifyLitQueriesControllerTypes: true,
trimGeneratedMarkdown: true,
},
]
for (const pkg of packages) {
await generatePackageReferenceDocs(pkg)
}
console.log('\n✅ All markdown files have been processed!')
process.exit(0)

View File

@@ -0,0 +1,106 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as prettier from 'prettier'
/** Pairs of package labels and their corresponding paths */
type LabelerPair = [string, string]
function readPairsFromFs(): Array<LabelerPair> {
const ignored = new Set(['.DS_Store'])
const pairs: Array<LabelerPair> = []
// Add subfolders in the packages folder, i.e. packages/**
fs.readdirSync(path.resolve('packages'))
.filter((folder) => !ignored.has(folder))
.forEach((folder) => {
// Check if package.json exists for the folder before adding it
if (
fs.existsSync(
path.resolve(path.join('packages', folder, 'package.json')),
)
) {
pairs.push([`package: ${folder}`, `packages/${folder}/**/*`])
} else {
console.info(
`Skipping \`${folder}\` as it does not have a \`package.json\` file.`,
)
}
})
// Sort by package name in alphabetical order
pairs.sort((a, b) => a[0].localeCompare(b[0]))
return pairs
}
async function generateLabelerYaml(pairs: Array<LabelerPair>): Promise<string> {
function s(n = 1) {
return ' '.repeat(n)
}
// Convert the pairs into valid yaml
const formattedPairs = pairs
.map(([packageLabel, packagePath]) => {
const result = [
`'${packageLabel}':`,
`${s(2)}-${s(1)}changed-files:`,
`${s(4)}-${s(1)}any-glob-to-any-file:${s(1)}'${packagePath}'`,
].join('\n')
return result
})
.join('\n')
// Get the location of the Prettier config file
const prettierConfigPath = await prettier.resolveConfigFile()
if (!prettierConfigPath) {
throw new Error(
'No Prettier config file found. Please ensure you have a Prettier config file in your project.',
)
}
console.info('using prettier config file at:', prettierConfigPath)
// Resolve the Prettier config
const prettierConfig = await prettier.resolveConfig(prettierConfigPath)
console.info('using resolved prettier config:', prettierConfig)
// Format the YAML string using Prettier
const formattedStr = await prettier.format(formattedPairs, {
parser: 'yaml',
...prettierConfig,
})
return formattedStr
}
async function run() {
console.info('Generating labeler config...')
// Generate the pairs of package labels and their corresponding paths
const pairs = readPairsFromFs()
// Always add the docs folder
pairs.push(['documentation', 'docs/**/*'])
// Convert the pairs into valid yaml
const yamlStr = await generateLabelerYaml(pairs)
// Write to 'labeler-config.yml'
const configPath = path.resolve('labeler-config.yml')
fs.writeFileSync(configPath, yamlStr, {
encoding: 'utf-8',
})
console.info(`Generated labeler config at \`${configPath}\`!`)
return
}
try {
run().then(() => {
process.exit(0)
})
} catch (error) {
console.error('Error generating labeler config:', error)
process.exit(1)
}

View File

@@ -0,0 +1,40 @@
// @ts-check
/**
* @param {Object} opts - Options for building configurations.
* @param {string[]} opts.entry - The entry array.
* @returns {import('tsdown').UserConfig}
*/
export function modernConfig(opts) {
return {
entry: opts.entry,
format: ['cjs', 'esm'],
target: ['chrome91', 'firefox90', 'edge91', 'safari15', 'ios15', 'opera77'],
// Rolldown lowers private fields for browser target arrays. Keep the
// transform target at ES2022 to match the syntax that tsup emitted.
inputOptions: { transform: { target: 'es2022' } },
outDir: 'build/modern',
dts: true,
fixedExtension: false,
sourcemap: true,
clean: true,
}
}
/**
* @param {Object} opts - Options for building configurations.
* @param {string[]} opts.entry - The entry array.
* @returns {import('tsdown').UserConfig}
*/
export function legacyConfig(opts) {
return {
entry: opts.entry,
format: ['cjs', 'esm'],
target: ['es2020', 'node16'],
outDir: 'build/legacy',
dts: true,
fixedExtension: false,
sourcemap: true,
clean: true,
}
}

17
scripts/getViteAliases.js Normal file
View File

@@ -0,0 +1,17 @@
// @ts-check
import path from 'node:path'
import ts from 'typescript'
const tsconfig = ts.readConfigFile(
path.resolve(__dirname, '..', 'tsconfig.json'),
ts.sys.readFile,
).config
export const dynamicAliases = Object.entries(
tsconfig.compilerOptions.paths || {},
).reduce((aliases, [key, [value]]) => {
const aliasKey = key.replace('/*', '')
aliases[aliasKey] = path.resolve(value.replace('/*', ''))
return aliases
}, /** @type {Record<string, string>} */ ({}))

4
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["**/*"]
}

130
scripts/verify-links.ts Normal file
View File

@@ -0,0 +1,130 @@
import { existsSync, readFileSync, statSync } from 'node:fs'
import { extname, resolve, sep } from 'node:path'
import { glob } from 'tinyglobby'
// @ts-ignore Could not find a declaration file for module 'markdown-link-extractor'.
import markdownLinkExtractor from 'markdown-link-extractor'
const errors: Array<{
file: string
link: string
resolvedPath: string
reason: string
}> = []
function isRelativeLink(link: string) {
return (
!link.startsWith('/') &&
!link.startsWith('http://') &&
!link.startsWith('https://') &&
!link.startsWith('//') &&
!link.startsWith('#') &&
!link.startsWith('mailto:')
)
}
/** Remove any trailing .md */
function stripExtension(p: string): string {
return p.replace(`${extname(p)}`, '')
}
function relativeLinkExists(link: string, file: string): boolean {
// Remove hash if present
const linkWithoutHash = link.split('#')[0]
// If the link is empty after removing hash, it's not a file
if (!linkWithoutHash) return false
// Strip the file/link extensions
const filePath = stripExtension(file)
const linkPath = stripExtension(linkWithoutHash)
// Resolve the path relative to the markdown file's directory
// Nav up a level to simulate how links are resolved on the web
let absPath = resolve(filePath, '..', linkPath)
// Ensure the resolved path is within /docs
const docsRoot = resolve('docs')
if (!absPath.startsWith(docsRoot)) {
errors.push({
link,
file,
resolvedPath: absPath,
reason: 'Path outside /docs',
})
return false
}
// `resolve` uses the platform separator, but the patterns below are written
// with forward slashes, so match against a normalized copy of the path.
// Node accepts forward slashes on Windows too, so the result stays usable.
absPath = absPath.split(sep).join('/')
// Check if this is an example path
const isExample = absPath.includes('/examples/')
let exists = false
if (isExample) {
// Transform /docs/framework/{framework}/examples/ to /examples/{framework}/
absPath = absPath.replace(
/\/docs\/framework\/([^/]+)\/examples\//,
'/examples/$1/',
)
// For examples, we want to check if the directory exists
exists = existsSync(absPath) && statSync(absPath).isDirectory()
} else {
// For non-examples, we want to check if the .md file exists
if (!absPath.endsWith('.md')) {
absPath = `${absPath}.md`
}
exists = existsSync(absPath)
}
if (!exists) {
errors.push({
link,
file,
resolvedPath: absPath,
reason: 'Not found',
})
}
return exists
}
async function verifyMarkdownLinks() {
// Find all markdown files in docs directory
const markdownFiles = await glob('docs/**/*.md', {
ignore: ['**/node_modules/**'],
})
console.log(`Found ${markdownFiles.length} markdown files\n`)
// Process each file
for (const file of markdownFiles) {
const content = readFileSync(file, 'utf-8')
const links: Array<string> = markdownLinkExtractor(content)
const relativeLinks = links.filter((link: string) => {
return isRelativeLink(link)
})
if (relativeLinks.length > 0) {
relativeLinks.forEach((link) => {
relativeLinkExists(link, file)
})
}
}
if (errors.length > 0) {
console.log(`\n❌ Found ${errors.length} broken links:`)
errors.forEach((err) => {
console.log(
`${err.file}\n link: ${err.link}\n resolved: ${err.resolvedPath}\n why: ${err.reason}\n`,
)
})
process.exit(1)
} else {
console.log('\n✅ No broken links found!')
}
}
verifyMarkdownLinks().catch(console.error)