Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled
636 lines
17 KiB
TypeScript
636 lines
17 KiB
TypeScript
/**
|
|
* agents-md: Generate Next.js documentation index for AI coding agents.
|
|
*
|
|
* Downloads docs from GitHub via git sparse-checkout, builds a compact
|
|
* index of all doc files, and injects it into CLAUDE.md or AGENTS.md.
|
|
*/
|
|
|
|
import execa from 'execa'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import os from 'os'
|
|
|
|
interface NextjsVersionResult {
|
|
version: string | null
|
|
error?: string
|
|
}
|
|
|
|
export function getNextjsVersion(cwd: string): NextjsVersionResult {
|
|
try {
|
|
const nextPkgPath = require.resolve('next/package.json', { paths: [cwd] })
|
|
const pkg = JSON.parse(fs.readFileSync(nextPkgPath, 'utf-8'))
|
|
return { version: pkg.version }
|
|
} catch {
|
|
// Not found at root - check for monorepo workspace
|
|
const workspace = detectWorkspace(cwd)
|
|
if (workspace.isMonorepo && workspace.packages.length > 0) {
|
|
const highestVersion = findNextjsInWorkspace(cwd, workspace.packages)
|
|
|
|
if (highestVersion) {
|
|
return { version: highestVersion }
|
|
}
|
|
|
|
return {
|
|
version: null,
|
|
error: `No Next.js found in ${workspace.type} workspace packages.`,
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: null,
|
|
error: 'Next.js is not installed in this project.',
|
|
}
|
|
}
|
|
}
|
|
|
|
function versionToGitHubTag(version: string): string {
|
|
return version.startsWith('v') ? version : `v${version}`
|
|
}
|
|
|
|
interface PullOptions {
|
|
cwd: string
|
|
version?: string
|
|
docsDir?: string
|
|
}
|
|
|
|
interface PullResult {
|
|
success: boolean
|
|
docsPath?: string
|
|
nextjsVersion?: string
|
|
error?: string
|
|
}
|
|
|
|
export async function pullDocs(options: PullOptions): Promise<PullResult> {
|
|
const { cwd, version: versionOverride, docsDir } = options
|
|
|
|
let nextjsVersion: string
|
|
|
|
if (versionOverride) {
|
|
nextjsVersion = versionOverride
|
|
} else {
|
|
const versionResult = getNextjsVersion(cwd)
|
|
if (!versionResult.version) {
|
|
return {
|
|
success: false,
|
|
error: versionResult.error || 'Could not detect Next.js version',
|
|
}
|
|
}
|
|
nextjsVersion = versionResult.version
|
|
}
|
|
|
|
const docsPath =
|
|
docsDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'next-agents-md-'))
|
|
const useTempDir = !docsDir
|
|
|
|
try {
|
|
if (useTempDir && fs.existsSync(docsPath)) {
|
|
fs.rmSync(docsPath, { recursive: true })
|
|
}
|
|
|
|
const tag = versionToGitHubTag(nextjsVersion)
|
|
await cloneDocsFolder(tag, docsPath)
|
|
|
|
return {
|
|
success: true,
|
|
docsPath,
|
|
nextjsVersion,
|
|
}
|
|
} catch (error) {
|
|
if (useTempDir && fs.existsSync(docsPath)) {
|
|
fs.rmSync(docsPath, { recursive: true })
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cloneDocsFolder(tag: string, destDir: string): Promise<void> {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'next-agents-md-'))
|
|
|
|
try {
|
|
try {
|
|
await execa(
|
|
'git',
|
|
[
|
|
'clone',
|
|
'--depth',
|
|
'1',
|
|
'--filter=blob:none',
|
|
'--sparse',
|
|
'--branch',
|
|
tag,
|
|
'https://github.com/vercel/next.js.git',
|
|
'.',
|
|
],
|
|
{ cwd: tempDir }
|
|
)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (message.includes('not found') || message.includes('did not match')) {
|
|
throw new Error(
|
|
`Could not find documentation for Next.js ${tag}. This version may not exist on GitHub yet.`
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
await execa('git', ['sparse-checkout', 'set', 'docs'], { cwd: tempDir })
|
|
|
|
const sourceDocsDir = path.join(tempDir, 'docs')
|
|
if (!fs.existsSync(sourceDocsDir)) {
|
|
throw new Error('docs folder not found in cloned repository')
|
|
}
|
|
|
|
if (fs.existsSync(destDir)) {
|
|
fs.rmSync(destDir, { recursive: true })
|
|
}
|
|
fs.mkdirSync(destDir, { recursive: true })
|
|
fs.cpSync(sourceDocsDir, destDir, { recursive: true })
|
|
} finally {
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
export function collectDocFiles(dir: string): { relativePath: string }[] {
|
|
return (fs.readdirSync(dir, { recursive: true }) as string[])
|
|
.filter(
|
|
(f) =>
|
|
(f.endsWith('.mdx') || f.endsWith('.md')) &&
|
|
!/[/\\]index\.mdx$/.test(f) &&
|
|
!/[/\\]index\.md$/.test(f) &&
|
|
!f.startsWith('index.')
|
|
)
|
|
.sort()
|
|
.map((f) => ({ relativePath: f.replace(/\\/g, '/') }))
|
|
}
|
|
|
|
interface DocSection {
|
|
name: string
|
|
files: { relativePath: string }[]
|
|
subsections: DocSection[]
|
|
}
|
|
|
|
export function buildDocTree(files: { relativePath: string }[]): DocSection[] {
|
|
const sections: Map<string, DocSection> = new Map()
|
|
|
|
for (const file of files) {
|
|
const parts = file.relativePath.split(/[/\\]/)
|
|
if (parts.length < 2) continue
|
|
|
|
const topLevelDir = parts[0]
|
|
|
|
if (!sections.has(topLevelDir)) {
|
|
sections.set(topLevelDir, {
|
|
name: topLevelDir,
|
|
files: [],
|
|
subsections: [],
|
|
})
|
|
}
|
|
|
|
const section = sections.get(topLevelDir)!
|
|
|
|
if (parts.length === 2) {
|
|
section.files.push({ relativePath: file.relativePath })
|
|
} else {
|
|
const subsectionDir = parts[1]
|
|
let subsection = section.subsections.find((s) => s.name === subsectionDir)
|
|
|
|
if (!subsection) {
|
|
subsection = { name: subsectionDir, files: [], subsections: [] }
|
|
section.subsections.push(subsection)
|
|
}
|
|
|
|
if (parts.length === 3) {
|
|
subsection.files.push({ relativePath: file.relativePath })
|
|
} else {
|
|
const subSubDir = parts[2]
|
|
let subSubsection = subsection.subsections.find(
|
|
(s) => s.name === subSubDir
|
|
)
|
|
|
|
if (!subSubsection) {
|
|
subSubsection = { name: subSubDir, files: [], subsections: [] }
|
|
subsection.subsections.push(subSubsection)
|
|
}
|
|
|
|
subSubsection.files.push({ relativePath: file.relativePath })
|
|
}
|
|
}
|
|
}
|
|
|
|
const sortedSections = Array.from(sections.values()).sort((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
)
|
|
|
|
for (const section of sortedSections) {
|
|
section.files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
section.subsections.sort((a, b) => a.name.localeCompare(b.name))
|
|
for (const subsection of section.subsections) {
|
|
subsection.files.sort((a, b) =>
|
|
a.relativePath.localeCompare(b.relativePath)
|
|
)
|
|
subsection.subsections.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
}
|
|
|
|
return sortedSections
|
|
}
|
|
|
|
interface ClaudeMdIndexData {
|
|
docsPath: string
|
|
sections: DocSection[]
|
|
outputFile?: string
|
|
}
|
|
|
|
export function generateClaudeMdIndex(data: ClaudeMdIndexData): string {
|
|
const { docsPath, sections, outputFile } = data
|
|
|
|
const parts: string[] = []
|
|
|
|
parts.push('[Next.js Docs Index]')
|
|
parts.push(`root: ${docsPath}`)
|
|
parts.push(
|
|
'STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.'
|
|
)
|
|
const targetFile = outputFile || 'CLAUDE.md'
|
|
parts.push(
|
|
`If docs missing, run this command first: npx @next/codemod agents-md --output ${targetFile}`
|
|
)
|
|
|
|
const allFiles = collectAllFilesFromSections(sections)
|
|
const grouped = groupByDirectory(allFiles)
|
|
|
|
for (const [dir, files] of grouped) {
|
|
parts.push(`${dir}:{${files.join(',')}}`)
|
|
}
|
|
|
|
return parts.join('|')
|
|
}
|
|
|
|
function collectAllFilesFromSections(sections: DocSection[]): string[] {
|
|
const files: string[] = []
|
|
|
|
for (const section of sections) {
|
|
for (const file of section.files) {
|
|
files.push(file.relativePath)
|
|
}
|
|
files.push(...collectAllFilesFromSections(section.subsections))
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
function groupByDirectory(files: string[]): Map<string, string[]> {
|
|
const grouped = new Map<string, string[]>()
|
|
|
|
for (const filePath of files) {
|
|
const lastSlash = Math.max(
|
|
filePath.lastIndexOf('/'),
|
|
filePath.lastIndexOf('\\')
|
|
)
|
|
const dir = lastSlash === -1 ? '.' : filePath.slice(0, lastSlash)
|
|
const fileName = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1)
|
|
|
|
const existing = grouped.get(dir)
|
|
if (existing) {
|
|
existing.push(fileName)
|
|
} else {
|
|
grouped.set(dir, [fileName])
|
|
}
|
|
}
|
|
|
|
return grouped
|
|
}
|
|
|
|
const START_MARKER = '<!-- NEXT-AGENTS-MD-START -->'
|
|
const END_MARKER = '<!-- NEXT-AGENTS-MD-END -->'
|
|
|
|
function hasExistingIndex(content: string): boolean {
|
|
return content.includes(START_MARKER)
|
|
}
|
|
|
|
function wrapWithMarkers(content: string): string {
|
|
return `${START_MARKER}${content}${END_MARKER}`
|
|
}
|
|
|
|
export function injectIntoClaudeMd(
|
|
claudeMdContent: string,
|
|
indexContent: string
|
|
): string {
|
|
const wrappedContent = wrapWithMarkers(indexContent)
|
|
|
|
if (hasExistingIndex(claudeMdContent)) {
|
|
const startIdx = claudeMdContent.indexOf(START_MARKER)
|
|
const endIdx = claudeMdContent.indexOf(END_MARKER) + END_MARKER.length
|
|
|
|
return (
|
|
claudeMdContent.slice(0, startIdx) +
|
|
wrappedContent +
|
|
claudeMdContent.slice(endIdx)
|
|
)
|
|
}
|
|
|
|
const separator = claudeMdContent.endsWith('\n') ? '\n' : '\n\n'
|
|
return claudeMdContent + separator + wrappedContent + '\n'
|
|
}
|
|
|
|
interface GitignoreStatus {
|
|
path: string
|
|
updated: boolean
|
|
alreadyPresent: boolean
|
|
}
|
|
|
|
const GITIGNORE_ENTRY = '.next-docs/'
|
|
|
|
export function ensureGitignoreEntry(cwd: string): GitignoreStatus {
|
|
const gitignorePath = path.join(cwd, '.gitignore')
|
|
const entryRegex = /^\s*\.next-docs(?:\/.*)?$/
|
|
|
|
let content = ''
|
|
if (fs.existsSync(gitignorePath)) {
|
|
content = fs.readFileSync(gitignorePath, 'utf-8')
|
|
}
|
|
|
|
const hasEntry = content.split(/\r?\n/).some((line) => entryRegex.test(line))
|
|
|
|
if (hasEntry) {
|
|
return { path: gitignorePath, updated: false, alreadyPresent: true }
|
|
}
|
|
|
|
const needsNewline = content.length > 0 && !content.endsWith('\n')
|
|
const header = content.includes('# next-agents-md')
|
|
? ''
|
|
: '# next-agents-md\n'
|
|
const newContent =
|
|
content + (needsNewline ? '\n' : '') + header + `${GITIGNORE_ENTRY}\n`
|
|
|
|
fs.writeFileSync(gitignorePath, newContent, 'utf-8')
|
|
|
|
return { path: gitignorePath, updated: true, alreadyPresent: false }
|
|
}
|
|
|
|
type WorkspaceType = 'pnpm' | 'npm' | 'yarn' | 'nx' | 'lerna' | null
|
|
|
|
interface WorkspaceInfo {
|
|
isMonorepo: boolean
|
|
type: WorkspaceType
|
|
packages: string[]
|
|
}
|
|
|
|
function detectWorkspace(cwd: string): WorkspaceInfo {
|
|
const packageJsonPath = path.join(cwd, 'package.json')
|
|
|
|
// Check pnpm workspaces (pnpm-workspace.yaml)
|
|
const pnpmWorkspacePath = path.join(cwd, 'pnpm-workspace.yaml')
|
|
if (fs.existsSync(pnpmWorkspacePath)) {
|
|
const packages = parsePnpmWorkspace(pnpmWorkspacePath)
|
|
if (packages.length > 0) {
|
|
return { isMonorepo: true, type: 'pnpm', packages }
|
|
}
|
|
}
|
|
|
|
// Check npm/yarn workspaces (package.json workspaces field)
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const packages = parsePackageJsonWorkspaces(packageJsonPath)
|
|
if (packages.length > 0) {
|
|
return { isMonorepo: true, type: 'npm', packages }
|
|
}
|
|
}
|
|
|
|
// Check Lerna (lerna.json)
|
|
const lernaPath = path.join(cwd, 'lerna.json')
|
|
if (fs.existsSync(lernaPath)) {
|
|
const packages = parseLernaConfig(lernaPath)
|
|
if (packages.length > 0) {
|
|
return { isMonorepo: true, type: 'lerna', packages }
|
|
}
|
|
}
|
|
|
|
// Check Nx (nx.json)
|
|
const nxPath = path.join(cwd, 'nx.json')
|
|
if (fs.existsSync(nxPath)) {
|
|
const packages = parseNxWorkspace(cwd, packageJsonPath)
|
|
if (packages.length > 0) {
|
|
return { isMonorepo: true, type: 'nx', packages }
|
|
}
|
|
}
|
|
|
|
return { isMonorepo: false, type: null, packages: [] }
|
|
}
|
|
|
|
function parsePnpmWorkspace(filePath: string): string[] {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
const lines = content.split('\n')
|
|
const packages: string[] = []
|
|
let inPackages = false
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (trimmed === 'packages:') {
|
|
inPackages = true
|
|
continue
|
|
}
|
|
if (inPackages) {
|
|
if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('#')) {
|
|
break
|
|
}
|
|
const match = trimmed.match(/^-\s*['"]?([^'"]+)['"]?$/)
|
|
if (match) {
|
|
packages.push(match[1])
|
|
}
|
|
}
|
|
}
|
|
return packages
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function parsePackageJsonWorkspaces(filePath: string): string[] {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
const pkg = JSON.parse(content)
|
|
if (Array.isArray(pkg.workspaces)) {
|
|
return pkg.workspaces
|
|
}
|
|
if (pkg.workspaces?.packages && Array.isArray(pkg.workspaces.packages)) {
|
|
return pkg.workspaces.packages
|
|
}
|
|
return []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function parseLernaConfig(filePath: string): string[] {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
const config = JSON.parse(content)
|
|
if (Array.isArray(config.packages)) {
|
|
return config.packages
|
|
}
|
|
return ['packages/*']
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function parseNxWorkspace(cwd: string, packageJsonPath: string): string[] {
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const packages = parsePackageJsonWorkspaces(packageJsonPath)
|
|
if (packages.length > 0) {
|
|
return packages
|
|
}
|
|
}
|
|
const defaultPatterns = ['apps/*', 'libs/*', 'packages/*']
|
|
const existingPatterns: string[] = []
|
|
for (const pattern of defaultPatterns) {
|
|
const basePath = path.join(cwd, pattern.replace('/*', ''))
|
|
if (fs.existsSync(basePath)) {
|
|
existingPatterns.push(pattern)
|
|
}
|
|
}
|
|
return existingPatterns
|
|
}
|
|
|
|
function findNextjsInWorkspace(cwd: string, patterns: string[]): string | null {
|
|
const packagePaths = expandWorkspacePatterns(cwd, patterns)
|
|
const versions: string[] = []
|
|
|
|
for (const pkgPath of packagePaths) {
|
|
try {
|
|
const nextPkgPath = require.resolve('next/package.json', {
|
|
paths: [pkgPath],
|
|
})
|
|
const pkg = JSON.parse(fs.readFileSync(nextPkgPath, 'utf-8'))
|
|
if (pkg.version) {
|
|
versions.push(pkg.version)
|
|
}
|
|
} catch {
|
|
// Next.js not installed in this package
|
|
}
|
|
}
|
|
|
|
return findHighestVersion(versions)
|
|
}
|
|
|
|
function expandWorkspacePatterns(cwd: string, patterns: string[]): string[] {
|
|
const packagePaths: string[] = []
|
|
|
|
for (const pattern of patterns) {
|
|
if (pattern.startsWith('!')) continue
|
|
|
|
if (pattern.includes('*')) {
|
|
packagePaths.push(...expandGlobPattern(cwd, pattern))
|
|
} else {
|
|
const fullPath = path.join(cwd, pattern)
|
|
if (fs.existsSync(fullPath)) {
|
|
packagePaths.push(fullPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...new Set(packagePaths)]
|
|
}
|
|
|
|
function expandGlobPattern(cwd: string, pattern: string): string[] {
|
|
const parts = pattern.split('/')
|
|
const results: string[] = []
|
|
|
|
function walk(currentPath: string, partIndex: number): void {
|
|
if (partIndex >= parts.length) {
|
|
if (fs.existsSync(path.join(currentPath, 'package.json'))) {
|
|
results.push(currentPath)
|
|
}
|
|
return
|
|
}
|
|
|
|
const part = parts[partIndex]
|
|
|
|
if (part === '*') {
|
|
if (!fs.existsSync(currentPath)) return
|
|
try {
|
|
for (const entry of fs.readdirSync(currentPath)) {
|
|
const fullPath = path.join(currentPath, entry)
|
|
if (isDirectory(fullPath)) {
|
|
if (partIndex === parts.length - 1) {
|
|
if (fs.existsSync(path.join(fullPath, 'package.json'))) {
|
|
results.push(fullPath)
|
|
}
|
|
} else {
|
|
walk(fullPath, partIndex + 1)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Permission denied
|
|
}
|
|
} else if (part === '**') {
|
|
walkRecursive(currentPath, results)
|
|
} else {
|
|
walk(path.join(currentPath, part), partIndex + 1)
|
|
}
|
|
}
|
|
|
|
walk(cwd, 0)
|
|
return results
|
|
}
|
|
|
|
function walkRecursive(dir: string, results: string[]): void {
|
|
if (!fs.existsSync(dir)) return
|
|
|
|
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
|
results.push(dir)
|
|
}
|
|
|
|
try {
|
|
for (const entry of fs.readdirSync(dir)) {
|
|
if (entry === 'node_modules' || entry.startsWith('.')) continue
|
|
const fullPath = path.join(dir, entry)
|
|
if (isDirectory(fullPath)) {
|
|
walkRecursive(fullPath, results)
|
|
}
|
|
}
|
|
} catch {
|
|
// Permission denied
|
|
}
|
|
}
|
|
|
|
function isDirectory(dirPath: string): boolean {
|
|
try {
|
|
return fs.statSync(dirPath).isDirectory()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function findHighestVersion(versions: string[]): string | null {
|
|
if (versions.length === 0) return null
|
|
if (versions.length === 1) return versions[0]
|
|
|
|
return versions.reduce((highest, current) => {
|
|
return compareVersions(current, highest) > 0 ? current : highest
|
|
})
|
|
}
|
|
|
|
function compareVersions(a: string, b: string): number {
|
|
const parseVersion = (v: string) => {
|
|
const match = v.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
if (!match) return [0, 0, 0]
|
|
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]
|
|
}
|
|
|
|
const [aMajor, aMinor, aPatch] = parseVersion(a)
|
|
const [bMajor, bMinor, bPatch] = parseVersion(b)
|
|
|
|
if (aMajor !== bMajor) return aMajor - bMajor
|
|
if (aMinor !== bMinor) return aMinor - bMinor
|
|
return aPatch - bPatch
|
|
}
|