11import { type ExecSyncOptions , execSync } from "node:child_process"
22import { cpSync , existsSync , mkdirSync , mkdtempSync , rmSync , writeFileSync } from "node:fs"
33import os from "node:os"
4- import { join , relative , resolve } from "node:path"
4+ import path , { join , resolve , posix as pathPosix } from "node:path"
55import { parseArgs } from "node:util"
66import chalk from "chalk"
77import semver from "semver"
8+ import { getServerEnv } from "~/env.server"
89
910type RunOpts = { cwd ?: string ; inherit ?: boolean }
1011function run ( cmd : string , opts : RunOpts = { } ) {
@@ -31,32 +32,19 @@ const resetDir = (p: string) => {
3132}
3233
3334const contentDir = "content"
34- const workspaceRoot = process . cwd ( )
35- const outputDir = resolve ( workspaceRoot , "generated-docs" )
36-
37- // If this script is executed from a subdirectory of the repository (for example
38- // the `docs/` package inside a monorepo), we need the path of that subdir
39- // relative to the repository root so worktrees point at the right package.
40- let repoRoot = workspaceRoot
41- let workspaceRelativePath = ""
35+ const outputDir = "generated-docs"
36+ const APP_ENV = getServerEnv ( ) . APP_ENV as "development" | "production"
37+ const currentDocsWorkspace = process . cwd ( )
38+
39+ let docsRelative = ""
4240try {
43- // This will return the repository top-level directory.
44- repoRoot = run ( "git rev-parse --show-toplevel" )
45- // Compute the path from repo root to the current working dir. If empty,
46- // we are at repo root and no adjustment is needed.
47- // Use posix-style join/resolve via path utilities already imported.
48- workspaceRelativePath = repoRoot === workspaceRoot ? "" : relative ( repoRoot , workspaceRoot )
41+ docsRelative = run ( "git rev-parse --show-prefix" , { cwd : currentDocsWorkspace } ) . replace ( / \/ ? $ / , "" )
4942} catch {
50- // If git is not available or the command fails, fall back to assuming the
51- // current working directory is the repository root.
52- repoRoot = workspaceRoot
53- workspaceRelativePath = ""
43+ docsRelative = ""
5444}
5545
56- // biome-ignore lint/suspicious/noConsole: TODO remove this
57- console . log ( chalk . cyan ( `Docs workspace root: ${ workspaceRoot } ` ) )
58- // biome-ignore lint/suspicious/noConsole: TODO remove this
59- console . log ( "outputDir:" , outputDir )
46+ const repoPath = ( ...segs : string [ ] ) => path . normalize ( path . join ( ...segs . filter ( Boolean ) ) )
47+
6048const allTags = ( ) => run ( "git tag --list" ) . split ( "\n" ) . filter ( Boolean )
6149
6250function resolveTagsFromSpec ( spec : string ) {
@@ -71,6 +59,16 @@ function resolveTagsFromSpec(spec: string) {
7159 return matched . sort ( semver . rcompare )
7260}
7361
62+ function detectDefaultBranch ( ) {
63+ try {
64+ const ref = run ( "git symbolic-ref refs/remotes/origin/HEAD" )
65+ const parts = ref . split ( "/" )
66+ return parts [ parts . length - 1 ]
67+ } catch {
68+ throw new Error ( "Cannot detect default branch from origin/HEAD" )
69+ }
70+ }
71+
7472function hasLocalRef ( ref : string ) {
7573 try {
7674 run ( `git show-ref --verify --quiet ${ ref } ` )
@@ -80,51 +78,37 @@ function hasLocalRef(ref: string) {
8078 }
8179}
8280
83- function buildDocs ( sourceDir : string , outDir : string ) {
84- if ( ! existsSync ( sourceDir ) ) {
85- throw new Error (
86- `❌ Documentation workspace not found at: ${ sourceDir }
87- Cannot build documentation without a valid workspace directory.`
88- )
81+ const REPO_ROOT = run ( "git rev-parse --show-toplevel" , { cwd : currentDocsWorkspace } )
82+
83+ function refHasPath ( ref : string , pathFromRepoRoot : string ) : boolean {
84+ const p = pathFromRepoRoot . replace ( / ^ \/ + / , "" ) . replace ( / \/ + $ / , "" )
85+ try {
86+ run ( `git -C "${ REPO_ROOT } " rev-parse --verify --quiet "${ ref } :${ p } "` )
87+ return true
88+ } catch {
89+ return false
8990 }
91+ }
92+
93+ function buildDocs ( sourceDir : string , outDir : string ) {
94+ if ( ! existsSync ( sourceDir ) ) throw new Error ( `Docs workspace not found: ${ sourceDir } ` )
9095
91- // biome-ignore lint/suspicious/noConsole: TODO remove this
92- console . log ( chalk . cyan ( `Building docs from: ${ sourceDir } → ${ outDir } ` ) )
9396 const docsContentDir = resolve ( sourceDir , contentDir )
9497 if ( ! existsSync ( docsContentDir ) ) {
95- throw new Error (
96- `❌ Content directory "${ contentDir } " not found at: ${ docsContentDir }
97- Cannot build documentation without content files.
98- Please ensure you have a "${ contentDir } /" directory with your documentation content.`
99- )
100- }
101-
102- const packageJsonPath = resolve ( sourceDir , "package.json" )
103- if ( ! existsSync ( packageJsonPath ) ) {
104- throw new Error (
105- `❌ package.json not found at: ${ packageJsonPath }
106- Cannot build documentation without package.json.
107- Please ensure your workspace has a valid package.json file.`
108- )
98+ throw new Error ( `Docs content directory "${ contentDir } " not found at ${ docsContentDir } ` )
10999 }
110100
111101 resetDir ( outDir )
112102 run ( "pnpm run content-collections:build" , { cwd : sourceDir , inherit : true } )
113103
114104 const ccSrc = resolve ( sourceDir , ".content-collections" )
115105 const ccDest = join ( outDir , ".content-collections" )
116- if ( ! existsSync ( ccSrc ) ) {
117- throw new Error (
118- `❌ Build output missing at: ${ ccSrc }
119- Content collections build failed or did not produce output.
120- Please check the build logs above for errors.`
121- )
122- }
106+ if ( ! existsSync ( ccSrc ) ) throw new Error ( `Build output missing at ${ ccSrc } ` )
123107
124108 resetDir ( ccDest )
125109 cpSync ( ccSrc , ccDest , { recursive : true } )
126110
127- // biome-ignore lint/suspicious/noConsole: keep for debugging
111+ // biome-ignore lint/suspicious/noConsole: <explanation>
128112 console . log ( chalk . green ( `✔ Built docs → ${ ccDest } ` ) )
129113}
130114
@@ -134,12 +118,13 @@ function buildRef(ref: string, labelForOutDir: string) {
134118 const worktreePath = resolve ( tmpBase , safeLabel )
135119
136120 run ( `git worktree add --detach "${ worktreePath } " "${ ref } "` , {
137- cwd : workspaceRoot ,
121+ cwd : currentDocsWorkspace ,
138122 inherit : true ,
139123 } )
140124
141125 try {
142- // Install dependencies at root if package.json exists
126+ const docsWorkspace = docsRelative ? resolve ( worktreePath , docsRelative ) : worktreePath
127+
143128 const rootPkg = existsSync ( resolve ( worktreePath , "package.json" ) )
144129 const rootLock = existsSync ( resolve ( worktreePath , "pnpm-lock.yaml" ) )
145130 if ( rootPkg ) {
@@ -149,15 +134,17 @@ function buildRef(ref: string, labelForOutDir: string) {
149134 } )
150135 }
151136
152- // If this script was run from a subdirectory (for example `docs/` inside
153- // a monorepo), adjust the sourceDir inside the created worktree so we
154- // build the correct package.
155- const sourceDir = workspaceRelativePath ? resolve ( worktreePath , workspaceRelativePath ) : worktreePath
137+ const docsPkg = existsSync ( resolve ( docsWorkspace , "package.json" ) )
138+ const docsLock = existsSync ( resolve ( docsWorkspace , "pnpm-lock.yaml" ) )
139+ if ( docsPkg && docsLock ) {
140+ run ( "pnpm install --frozen-lockfile" , { cwd : docsWorkspace , inherit : true } )
141+ }
142+
156143 const outDir = resolve ( outputDir , labelForOutDir )
157- buildDocs ( sourceDir , outDir )
144+ buildDocs ( docsWorkspace , outDir )
158145 } finally {
159146 run ( `git worktree remove "${ worktreePath } " --force` , {
160- cwd : workspaceRoot ,
147+ cwd : currentDocsWorkspace ,
161148 inherit : true ,
162149 } )
163150 rmSync ( tmpBase , { recursive : true , force : true } )
@@ -166,7 +153,7 @@ function buildRef(ref: string, labelForOutDir: string) {
166153
167154function buildBranch ( branch : string , labelForOutDir : string ) {
168155 run ( `git fetch --tags --prune origin ${ branch } ` , {
169- cwd : workspaceRoot ,
156+ cwd : currentDocsWorkspace ,
170157 inherit : true ,
171158 } )
172159 const localRef = `refs/heads/${ branch } `
@@ -178,99 +165,88 @@ function buildTag(tag: string) {
178165 return buildRef ( `refs/tags/${ tag } ` , tag )
179166}
180167
181- function getCurrentBranch ( ) : string {
182- try {
183- return run ( "git rev-parse --abbrev-ref HEAD" )
184- } catch {
185- throw new Error ( "Failed to get current branch" )
186- }
168+ function isPullRequestCI ( ) {
169+ return process . env . GITHUB_EVENT_NAME === "pull_request" || ! ! process . env . GITHUB_HEAD_REF
187170}
188171
189- function isOnDefaultBranch ( defaultBranch : string ) : boolean {
190- const currentBranch = getCurrentBranch ( )
191- return currentBranch === defaultBranch
192- }
193172; ( async ( ) => {
194173 const { values } = parseArgs ( {
195174 args : process . argv . slice ( 2 ) ,
196175 options : {
197- versions : { type : "string" } , // optional: comma/space list of semver ranges or exact tags
198- branch : { type : "string" } , // required: specify default branch name (e.g., main, master, develop )
176+ versions : { type : "string" } , // optional: comma/space list of semver ranges or exact tags (e.g. "v1.0.0, v1.1.x")
177+ branch : { type : "string" } , // optional: default branch override (e.g. main)
199178 } ,
200179 } )
201180
202- // Get default branch from --branch flag (required)
203- const defaultBranch = ( values . branch as string | undefined ) ?. trim ( )
204- if ( ! defaultBranch ) {
205- throw new Error (
206- `❌ Missing required --branch flag.
207- Please specify the default branch name (e.g., --branch main)
208- Example: pnpm run generate:docs --branch main`
209- )
210- }
181+ const defaultBranch =
182+ ( values . branch as string | undefined ) ?. trim ( ) || detectDefaultBranch ( )
211183
212184 const rawVersions = ( values . versions as string | undefined ) ?. trim ( ) ?? ""
213185 const hasVersionsArg = rawVersions . length > 0
214186
215187 let builtVersions : string [ ] = [ ]
216188
217- // Determine which branch to build as "current"
218- const onDefaultBranch = isOnDefaultBranch ( defaultBranch )
219- const currentBranchToBuild = onDefaultBranch ? defaultBranch : getCurrentBranch ( )
220-
221- // biome-ignore lint/suspicious/noConsole: keep for logging
222- console . log ( chalk . cyan ( `Building from branch: ${ currentBranchToBuild } ` ) )
223-
224- if ( hasVersionsArg ) {
225- // Build specified version tags + current branch
226- const tags = resolveTagsFromSpec ( rawVersions )
227- if ( ! tags . length ) throw new Error ( `No tags matched spec "${ rawVersions } ".` )
228-
229- // biome-ignore lint/suspicious/noConsole: keep for logging
230- console . log ( chalk . cyan ( `Building tags: ${ tags . join ( ", " ) } ` ) )
231- for ( const t of tags ) buildTag ( t )
232-
233- // Build current branch
234- if ( onDefaultBranch ) {
235- // biome-ignore lint/suspicious/noConsole: keep for logging
236- console . log ( chalk . cyan ( `Building default branch '${ defaultBranch } ' → current` ) )
237- buildBranch ( defaultBranch , "current" )
189+ if ( APP_ENV === "development" ) {
190+ // Local dev: always build the current workspace → current
191+ console . log ( chalk . cyan ( `(dev) Building docs from current workspace: ${ currentDocsWorkspace } → current` ) )
192+ buildDocs ( currentDocsWorkspace , join ( outputDir , "current" ) )
193+ builtVersions = [ "current" ]
194+ } else if ( isPullRequestCI ( ) ) {
195+ // PR builds
196+ if ( hasVersionsArg ) {
197+ // Build PR content as "current" + requested tags
198+ console . log ( chalk . cyan ( `(pr) Building PR docs → current` ) )
199+ buildDocs ( currentDocsWorkspace , join ( outputDir , "current" ) )
200+
201+ const tags = resolveTagsFromSpec ( rawVersions )
202+ if ( ! tags . length ) throw new Error ( `No tags matched spec "${ rawVersions } ".` )
203+ console . log ( chalk . cyan ( `(pr) Also building tags: ${ tags . join ( ", " ) } ` ) )
204+ for ( const t of tags ) buildTag ( t )
205+
206+ builtVersions = [ "current" , ...tags ]
238207 } else {
239- // biome-ignore lint/suspicious/noConsole: keep for logging
240- console . log ( chalk . cyan ( "Building current workspace → current" ) )
241- buildDocs ( workspaceRoot , join ( outputDir , "current" ) )
208+ // Only PR content as "current"
209+ console . log ( chalk . cyan ( `(pr) Building PR docs → current` ) )
210+ buildDocs ( currentDocsWorkspace , join ( outputDir , "current" ) )
211+ builtVersions = [ "current" ]
242212 }
243-
244- builtVersions = [ "current" , ...tags ]
245213 } else {
246- // Build only current branch
247- if ( onDefaultBranch ) {
248- // biome-ignore lint/suspicious/noConsole: keep for logging
249- console . log ( chalk . cyan ( `Building default branch '${ defaultBranch } ' → current` ) )
250- buildBranch ( defaultBranch , "current" )
214+ // Non-PR (e.g., release)
215+ if ( hasVersionsArg ) {
216+ // Build exactly the versions provided (this keeps older docs available if you list them)
217+ const tags = resolveTagsFromSpec ( rawVersions )
218+ if ( ! tags . length ) throw new Error ( `No tags matched spec "${ rawVersions } ".` )
219+ console . log ( chalk . cyan ( `(ci) Building tags: ${ tags . join ( ", " ) } ` ) )
220+ for ( const t of tags ) buildTag ( t )
221+ builtVersions = [ ...tags ]
251222 } else {
252- // biome-ignore lint/suspicious/noConsole: keep for logging
253- console . log ( chalk . cyan ( "Building current workspace → current" ) )
254- buildDocs ( workspaceRoot , join ( outputDir , "current" ) )
223+ // Fallback: build default branch (useful if you want a "main" channel)
224+ const checkPath = repoPath ( docsRelative , contentDir ) // "docs/content"
225+ run ( `git fetch --prune origin ${ defaultBranch } ` , { cwd : currentDocsWorkspace , inherit : true } )
226+
227+ const hasOnDefault = refHasPath ( `origin/${ defaultBranch } ` , checkPath )
228+ if ( ! hasOnDefault ) {
229+ throw new Error (
230+ `Default branch 'origin/${ defaultBranch } ' has no '${ checkPath } '. Pass --versions to build tags.`
231+ )
232+ }
233+ console . log ( chalk . cyan ( `(ci) Building docs from '${ defaultBranch } ' → ${ defaultBranch } ` ) )
234+ buildBranch ( defaultBranch , defaultBranch )
235+ builtVersions = [ defaultBranch ]
255236 }
256-
257- builtVersions = [ "current" ]
258237 }
259238
260239 // Write versions file for the app
261- const versionsFile = resolve ( workspaceRoot , "app/utils/versions.ts" )
240+ const versionsFile = resolve ( "app/utils/versions.ts" )
262241 writeFileSync (
263242 versionsFile ,
264243 `// Auto-generated file. Do not edit manually.
265244export const versions = ${ JSON . stringify ( builtVersions , null , 2 ) } as const
266245`
267246 )
268- // biome-ignore lint/suspicious/noConsole: keep for logging
269247 console . log ( chalk . green ( `✔ Wrote versions.ts → ${ versionsFile } ` ) )
270- // biome-ignore lint/suspicious/noConsole: keep for logging
271248 console . log ( chalk . green ( "✅ Done" ) )
272249} ) ( ) . catch ( ( e ) => {
273- // biome-ignore lint/suspicious/noConsole: keep for logging
274250 console . error ( chalk . red ( "❌ Build failed:" ) , e )
275251 process . exit ( 1 )
276252} )
0 commit comments