33// Commands: dev, build, push
44
55import { resolve , join , dirname } from 'node:path' ;
6- import { existsSync , readFileSync , writeFileSync , mkdirSync } from 'node:fs' ;
6+ import { existsSync , readFileSync , writeFileSync , mkdirSync , readdirSync } from 'node:fs' ;
77import { homedir } from 'node:os' ;
88import { createInterface } from 'node:readline' ;
99import { scanApp , generateBridge } from './generator.ts' ;
@@ -341,6 +341,7 @@ async function main() {
341341 const serverUrl = getArg ( '--server' ) || config . server || globalConfig . server ;
342342 const appName = getArg ( '--name' ) || config . name ;
343343 const apiKey = resolveApiKey ( ) ;
344+ const isStaticPush = args . includes ( '--static' ) ;
344345
345346 if ( ! serverUrl ) {
346347 console . error ( '[magnetic] No server URL. Use --server <url> or set "server" in magnetic.json' ) ;
@@ -351,38 +352,108 @@ async function main() {
351352 process . exit ( 1 ) ;
352353 }
353354
354- log ( 'info' , `Building for deploy...` ) ;
355- const scan = scanApp ( appDir , monorepoRoot || undefined ) ;
356- const appConfig = parseAppConfig ( appDir ) ;
357- log ( 'info' , `Scanned: ${ scan . pages . length } pages, ${ scan . layouts . length } layouts, state: ${ scan . statePath || 'none' } ` ) ;
358- if ( appConfig . data . length > 0 ) log ( 'info' , `Data sources: ${ appConfig . data . length } ` ) ;
359- if ( appConfig . actions . length > 0 ) log ( 'info' , `Action mappings: ${ appConfig . actions . length } ` ) ;
360- const pushDesignJson = readDesignJson ( appDir ) ;
361- if ( pushDesignJson ) log ( 'info' , 'Design tokens: design.json loaded' ) ;
362- const pushContentMap = buildContentMap ( appDir ) ;
363- const pushContentInjection = pushContentMap ? generateContentInjection ( pushContentMap ) : undefined ;
364- if ( pushContentMap ) log ( 'info' , `Content: ${ Object . keys ( pushContentMap ) . length } markdown files` ) ;
365- const bridgeCode = generateBridge ( scan , appConfig , pushDesignJson ?? undefined , pushContentInjection ) ;
366- const deploy = await buildForDeploy ( { appDir, bridgeCode, monorepoRoot : monorepoRoot || undefined } ) ;
367- const serverConfig = serializeConfigForServer ( appConfig ) ;
368-
369- log ( 'info' , `Bundle: ${ ( deploy . bundleSize / 1024 ) . toFixed ( 1 ) } KB (minified)` ) ;
370- log ( 'info' , `Assets: ${ Object . keys ( deploy . assets ) . length } files` ) ;
371- for ( const [ name , content ] of Object . entries ( deploy . assets ) ) {
372- log ( 'debug' , ` asset: ${ name } (${ ( content . length / 1024 ) . toFixed ( 1 ) } KB)` ) ;
373- }
355+ let deployPayload : any ;
356+
357+ if ( isStaticPush ) {
358+ // ── Static (SSG) deployment ──────────────────────────────
359+ log ( 'info' , `Building static site for deploy...` ) ;
360+ const scan = scanApp ( appDir , monorepoRoot || undefined ) ;
361+ const appConfig = parseAppConfig ( appDir ) ;
362+ log ( 'info' , `Scanned: ${ scan . pages . length } pages, ${ scan . layouts . length } layouts, state: ${ scan . statePath || 'none' } ` ) ;
363+ const pushDesignJson = readDesignJson ( appDir ) ;
364+ if ( pushDesignJson ) log ( 'info' , 'Design tokens: design.json loaded' ) ;
365+ const pushContentMap = buildContentMap ( appDir ) ;
366+ const pushContentInjection = pushContentMap ? generateContentInjection ( pushContentMap ) : undefined ;
367+ const contentSlugs = pushContentMap ? Object . keys ( pushContentMap ) : [ ] ;
368+ if ( pushContentMap ) log ( 'info' , `Content: ${ contentSlugs . length } markdown files` ) ;
369+ const bridgeCode = generateBridge ( scan , appConfig , pushDesignJson ?? undefined , pushContentInjection ) ;
370+
371+ const result = await bundleApp ( {
372+ appDir,
373+ bridgeCode,
374+ minify : true ,
375+ monorepoRoot : monorepoRoot || undefined ,
376+ } ) ;
377+
378+ // Build prerender route list
379+ const prerenderList : string [ ] = [ '/' ] ;
380+ for ( const slug of contentSlugs ) prerenderList . push ( '/' + slug ) ;
381+ for ( const page of scan . pages ) {
382+ if ( page . routePath !== '/' && ! page . routePath . includes ( ':' ) && ! page . isCatchAll ) {
383+ prerenderList . push ( page . routePath ) ;
384+ }
385+ }
386+ const routes = [ ...new Set ( prerenderList ) ] ;
387+
388+ log ( 'info' , `Pre-rendering ${ routes . length } routes...` ) ;
389+ const { prerenderRoutes } = await import ( './prerender.ts' ) ;
390+ const distDir = join ( appDir , 'dist' ) ;
391+ await prerenderRoutes ( {
392+ bundlePath : result . outPath ,
393+ outDir : distDir ,
394+ routes,
395+ title : appConfig . name || 'Magnetic App' ,
396+ inlineCSS : undefined ,
397+ publicDir : join ( appDir , 'public' ) ,
398+ contentDir : undefined ,
399+ log,
400+ } ) ;
401+
402+ // Collect all files from dist/ (excluding app.js bundle) + public/
403+ const staticFiles : Record < string , string > = { } ;
404+ const collectFiles = ( dir : string , prefix : string ) => {
405+ if ( ! existsSync ( dir ) ) return ;
406+ for ( const entry of readdirSync ( dir , { withFileTypes : true } ) ) {
407+ if ( entry . isDirectory ( ) ) {
408+ collectFiles ( join ( dir , entry . name ) , prefix ? `${ prefix } /${ entry . name } ` : entry . name ) ;
409+ } else if ( entry . isFile ( ) && entry . name !== 'app.js' ) {
410+ const relPath = prefix ? `${ prefix } /${ entry . name } ` : entry . name ;
411+ staticFiles [ relPath ] = readFileSync ( join ( dir , entry . name ) , 'utf-8' ) ;
412+ }
413+ }
414+ } ;
415+ // Public assets (CSS, images, etc.)
416+ collectFiles ( join ( appDir , 'public' ) , '' ) ;
417+ // Prerendered HTML pages (overwrites any public/ conflicts)
418+ collectFiles ( distDir , '' ) ;
419+
420+ log ( 'info' , `Static files: ${ Object . keys ( staticFiles ) . length } files` ) ;
421+ for ( const [ name , content ] of Object . entries ( staticFiles ) ) {
422+ log ( 'debug' , ` ${ name } (${ ( content . length / 1024 ) . toFixed ( 1 ) } KB)` ) ;
423+ }
374424
375- const bundleContent = readFileSync ( deploy . bundlePath , 'utf-8' ) ;
425+ deployPayload = { name : appName , static : true , assets : staticFiles } ;
426+ } else {
427+ // ── SSR deployment ───────────────────────────────────────
428+ log ( 'info' , `Building for deploy...` ) ;
429+ const scan = scanApp ( appDir , monorepoRoot || undefined ) ;
430+ const appConfig = parseAppConfig ( appDir ) ;
431+ log ( 'info' , `Scanned: ${ scan . pages . length } pages, ${ scan . layouts . length } layouts, state: ${ scan . statePath || 'none' } ` ) ;
432+ if ( appConfig . data . length > 0 ) log ( 'info' , `Data sources: ${ appConfig . data . length } ` ) ;
433+ if ( appConfig . actions . length > 0 ) log ( 'info' , `Action mappings: ${ appConfig . actions . length } ` ) ;
434+ const pushDesignJson = readDesignJson ( appDir ) ;
435+ if ( pushDesignJson ) log ( 'info' , 'Design tokens: design.json loaded' ) ;
436+ const pushContentMap = buildContentMap ( appDir ) ;
437+ const pushContentInjection = pushContentMap ? generateContentInjection ( pushContentMap ) : undefined ;
438+ if ( pushContentMap ) log ( 'info' , `Content: ${ Object . keys ( pushContentMap ) . length } markdown files` ) ;
439+ const bridgeCode = generateBridge ( scan , appConfig , pushDesignJson ?? undefined , pushContentInjection ) ;
440+ const deploy = await buildForDeploy ( { appDir, bridgeCode, monorepoRoot : monorepoRoot || undefined } ) ;
441+ const serverConfig = serializeConfigForServer ( appConfig ) ;
442+
443+ log ( 'info' , `Bundle: ${ ( deploy . bundleSize / 1024 ) . toFixed ( 1 ) } KB (minified)` ) ;
444+ log ( 'info' , `Assets: ${ Object . keys ( deploy . assets ) . length } files` ) ;
445+ for ( const [ name , content ] of Object . entries ( deploy . assets ) ) {
446+ log ( 'debug' , ` asset: ${ name } (${ ( content . length / 1024 ) . toFixed ( 1 ) } KB)` ) ;
447+ }
448+
449+ const bundleContent = readFileSync ( deploy . bundlePath , 'utf-8' ) ;
450+ deployPayload = { name : appName , bundle : bundleContent , assets : deploy . assets , config : serverConfig } ;
451+ }
376452
377453 if ( apiKey ) {
378454 // Authenticated: deploy via control plane
379- log ( 'info' , `Deploying to ${ serverUrl } (authenticated)...` ) ;
380- const deployBody = Buffer . from ( JSON . stringify ( {
381- name : appName ,
382- bundle : bundleContent ,
383- assets : deploy . assets ,
384- config : serverConfig ,
385- } ) ) ;
455+ log ( 'info' , `Deploying ${ isStaticPush ? 'static site' : 'app' } to ${ serverUrl } (authenticated)...` ) ;
456+ const deployBody = Buffer . from ( JSON . stringify ( deployPayload ) ) ;
386457 const resp = await fetch ( `${ serverUrl } /api/deploy` , {
387458 method : 'POST' ,
388459 headers : {
@@ -397,6 +468,7 @@ async function main() {
397468 log ( 'info' , `✓ Deployed!` ) ;
398469 log ( 'info' , ` Live at: ${ data . url } ` ) ;
399470 log ( 'info' , ` App ID: ${ data . id } ` ) ;
471+ if ( data . static ) log ( 'info' , ` Mode: static (${ data . files } files)` ) ;
400472 } else {
401473 const text = await resp . text ( ) ;
402474 log ( 'error' , `Deploy failed (${ resp . status } ): ${ text } ` ) ;
@@ -405,11 +477,11 @@ async function main() {
405477 } else {
406478 // No auth: direct push to node (backward-compatible with --platform servers)
407479 log ( 'info' , `Pushing to ${ serverUrl } /api/apps/${ appName } /deploy...` ) ;
408- const directBody = Buffer . from ( JSON . stringify ( {
409- bundle : bundleContent ,
410- assets : deploy . assets ,
411- config : serverConfig ,
412- } ) ) ;
480+ const directBody = Buffer . from ( JSON . stringify (
481+ isStaticPush
482+ ? { static : true , assets : deployPayload . assets }
483+ : { bundle : deployPayload . bundle , assets : deployPayload . assets , config : deployPayload . config }
484+ ) ) ;
413485 const resp = await fetch ( `${ serverUrl } /api/apps/${ appName } /deploy` , {
414486 method : 'POST' ,
415487 headers : {
@@ -422,6 +494,7 @@ async function main() {
422494 const data = await resp . json ( ) as any ;
423495 log ( 'info' , `✓ Deployed! ${ data . url || serverUrl + '/apps/' + appName + '/' } ` ) ;
424496 log ( 'info' , ` Live at: ${ serverUrl } /apps/${ appName } /` ) ;
497+ if ( data . static ) log ( 'info' , ` Mode: static (${ data . files } files)` ) ;
425498 } else {
426499 const text = await resp . text ( ) ;
427500 log ( 'error' , `Deploy failed (${ resp . status } ): ${ text } ` ) ;
0 commit comments