1818 */
1919
2020import { readdirSync , readFileSync , writeFileSync , statSync , existsSync , mkdirSync } from 'fs' ;
21- import { join , dirname } from 'path' ;
21+ import { join , dirname , relative } from 'path' ;
2222import { fileURLToPath } from 'url' ;
2323import { execSync } from 'child_process' ;
2424
@@ -33,6 +33,7 @@ const CONFIG_PATH = join(ROOT_DIR, 'src', 'data', 'projects.json');
3333
3434// Canonical public URL (override via SITE_URL env var for staging builds).
3535const SITE_URL = ( process . env . SITE_URL || 'https://docs.mevera.studio' ) . replace ( / \/ $ / , '' ) ;
36+ const VERBOSE_DOCS = process . env . PRECOMPILE_VERBOSE === '1' ;
3637
3738type YamlValue = string | number | boolean ;
3839
@@ -109,6 +110,67 @@ function contributorKey(name: string, email: string): string {
109110 return `name:${ name . trim ( ) . toLowerCase ( ) } ` ;
110111}
111112
113+ function collectGitDocMetadata ( ) : Map < string , GitDocMetadata > {
114+ const metadataByPath = new Map < string , GitDocMetadata > ( ) ;
115+ const contributorKeysByPath = new Map < string , Set < string > > ( ) ;
116+
117+ try {
118+ const log = execSync ( 'git log --format="commit:%H%x1f%aI%x1f%an%x1f%ae" --name-only -- docs' , {
119+ cwd : ROOT_DIR ,
120+ encoding : 'utf-8' ,
121+ maxBuffer : 1024 * 1024 * 50 ,
122+ stdio : [ 'pipe' , 'pipe' , 'ignore' ] ,
123+ } ) ;
124+
125+ let currentCommit : { date : string ; name : string ; email : string } | null = null ;
126+
127+ for ( const rawLine of log . split ( / \r ? \n / ) ) {
128+ const line = rawLine . trim ( ) ;
129+ if ( ! line ) continue ;
130+
131+ if ( line . startsWith ( 'commit:' ) ) {
132+ const [ , date , name , email ] = line . slice ( 'commit:' . length ) . split ( '\x1f' ) ;
133+ currentCommit = { date, name, email } ;
134+ continue ;
135+ }
136+
137+ if ( ! currentCommit || ! / \. ( m d | m d x ) $ / i. test ( line ) ) continue ;
138+
139+ const metadata = metadataByPath . get ( line ) || { } ;
140+ if ( ! metadata . lastUpdatedAt ) {
141+ metadata . lastUpdatedAt = currentCommit . date ;
142+ }
143+
144+ const key = contributorKey ( currentCommit . name , currentCommit . email ) ;
145+ let contributorKeys = contributorKeysByPath . get ( line ) ;
146+ if ( ! contributorKeys ) {
147+ contributorKeys = new Set < string > ( ) ;
148+ contributorKeysByPath . set ( line , contributorKeys ) ;
149+ }
150+
151+ if ( ! contributorKeys . has ( key ) ) {
152+ contributorKeys . add ( key ) ;
153+ const contributors = metadata . contributors || [ ] ;
154+ if ( contributors . length < 10 ) {
155+ const githubUsername = githubUsernameFromEmail ( currentCommit . email ) ;
156+ contributors . push ( {
157+ name : currentCommit . name ,
158+ email : currentCommit . email ,
159+ avatar : githubUsername ? `https://github.com/${ githubUsername } .png?size=64` : undefined ,
160+ } ) ;
161+ metadata . contributors = contributors ;
162+ }
163+ }
164+
165+ metadataByPath . set ( line , metadata ) ;
166+ }
167+ } catch {
168+ // Git metadata is best-effort. Builds outside a git checkout still work.
169+ }
170+
171+ return metadataByPath ;
172+ }
173+
112174interface DocCategory {
113175 name : string ;
114176 docs : DocFile [ ] ;
@@ -192,6 +254,15 @@ interface SearchIndexItem {
192254 category : string ;
193255}
194256
257+ interface GitDocMetadata {
258+ lastUpdatedAt ?: string ;
259+ contributors ?: DocContributor [ ] ;
260+ }
261+
262+ function toGitPath ( path : string ) : string {
263+ return relative ( ROOT_DIR , path ) . replace ( / \\ / g, '/' ) ;
264+ }
265+
195266/**
196267 * Parse YAML-like content (simplified parser)
197268 */
@@ -424,6 +495,7 @@ function buildVersion(
424495 projectName : string ,
425496 versionDir : string ,
426497 versionMeta : { id : string ; label : string ; latest : boolean } ,
498+ gitMetadataByPath : Map < string , GitDocMetadata > ,
427499 tocMap : Record < string , TocItem [ ] > ,
428500 searchIndex : SearchIndexItem [ ] ,
429501 indexLatestOnlyForSearch : boolean
@@ -487,51 +559,7 @@ function buildVersion(
487559 categoryName = frontmatter . category ;
488560 }
489561
490- // Get last updated date from git
491- let lastUpdatedAt : string | undefined = undefined ;
492- try {
493- const stdout = execSync ( `git log -1 --format="%aI" -- "${ fullPath } "` , {
494- encoding : 'utf-8' ,
495- stdio : [ 'pipe' , 'pipe' , 'ignore' ]
496- } ) . trim ( ) ;
497-
498- if ( stdout ) {
499- lastUpdatedAt = stdout ;
500- }
501- } catch {
502- // Ignore errors (e.g., file not tracked by git yet)
503- }
504-
505- // Collect unique contributors from git log.
506- let contributors : DocContributor [ ] = [ ] ;
507- try {
508- const log = execSync ( `git log --format="%an|%ae" -- "${ fullPath } "` , {
509- encoding : 'utf-8' ,
510- stdio : [ 'pipe' , 'pipe' , 'ignore' ]
511- } ) . trim ( ) ;
512-
513- const seen = new Set < string > ( ) ;
514- for ( const line of log . split ( '\n' ) ) {
515- if ( ! line ) continue ;
516- const pipeIdx = line . indexOf ( '|' ) ;
517- if ( pipeIdx < 0 ) continue ;
518- const name = line . slice ( 0 , pipeIdx ) . trim ( ) ;
519- const email = line . slice ( pipeIdx + 1 ) . trim ( ) ;
520- const key = contributorKey ( name , email ) ;
521- if ( seen . has ( key ) ) continue ;
522- seen . add ( key ) ;
523-
524- let avatar : string | undefined ;
525- const githubUsername = githubUsernameFromEmail ( email ) ;
526- if ( githubUsername ) {
527- avatar = `https://github.com/${ githubUsername } .png?size=64` ;
528- }
529- contributors . push ( { name, email, avatar } ) ;
530- }
531- contributors = contributors . slice ( 0 , 10 ) ;
532- } catch {
533- // ignore
534- }
562+ const gitMetadata = gitMetadataByPath . get ( toGitPath ( fullPath ) ) ;
535563
536564 const docFile : DocFile = {
537565 slug,
@@ -542,8 +570,8 @@ function buildVersion(
542570 version : versionMeta . id ,
543571 category : categoryName ,
544572 extension,
545- lastUpdatedAt,
546- contributors : contributors . length > 0 ? contributors : undefined ,
573+ lastUpdatedAt : gitMetadata ?. lastUpdatedAt ,
574+ contributors : gitMetadata ?. contributors ,
547575 } ;
548576
549577 allDocs . push ( docFile ) ;
@@ -566,7 +594,9 @@ function buildVersion(
566594 } ) ;
567595 }
568596
569- console . log ( ` ✓ ${ versionMeta . id } /${ slug } ${ extension } ` ) ;
597+ if ( VERBOSE_DOCS ) {
598+ console . log ( ` ✓ ${ versionMeta . id } /${ slug } ${ extension } ` ) ;
599+ }
570600 } ) ;
571601
572602 // Group docs into categories.
@@ -620,6 +650,7 @@ function precompileDocs() {
620650 const projectsMap = new Map < string , DocProject > ( ) ;
621651 const searchIndex : SearchIndexItem [ ] = [ ] ;
622652 const tocMap : Record < string , TocItem [ ] > = { } ;
653+ const gitMetadataByPath = collectGitDocMetadata ( ) ;
623654
624655 // Process each declared project.
625656 projectsConfig . forEach ( ( projectCfg ) => {
@@ -644,12 +675,16 @@ function precompileDocs() {
644675 projectCfg . title ,
645676 join ( projectDir , v . id ) ,
646677 v ,
678+ gitMetadataByPath ,
647679 tocMap ,
648680 searchIndex ,
649681 /* indexLatestOnlyForSearch = */ true ,
650682 )
651683 ) ;
652684
685+ const projectDocCount = versions . reduce ( ( sum , version ) => sum + version . allDocs . length , 0 ) ;
686+ console . log ( ` ✓ ${ projectDocCount } docs across ${ versions . length } version(s)` ) ;
687+
653688 projectsMap . set ( projectCfg . id , {
654689 id : projectCfg . id ,
655690 name : projectCfg . title ,
0 commit comments