@@ -81,14 +81,82 @@ function ensureMeander(refresh: boolean): void {
8181 }
8282}
8383
84+ /**
85+ * Normalize a base-path argument — enforce a single leading `/`, strip
86+ * trailing `/`. Empty input means "served at the origin root" (no
87+ * rewrite needed). Input of just `/` also normalizes to empty.
88+ */
89+ function normalizeBasePath ( raw : string ) : string {
90+ let p = raw . trim ( )
91+ if ( ! p || p === '/' ) {
92+ return ''
93+ }
94+ if ( ! p . startsWith ( '/' ) ) {
95+ p = '/' + p
96+ }
97+ if ( p . endsWith ( '/' ) ) {
98+ p = p . slice ( 0 , - 1 )
99+ }
100+ return p
101+ }
102+
103+ /**
104+ * Rewrite a generated HTML file for hosting under `basePath`. Two
105+ * categories of URL get prefixed:
106+ *
107+ * 1. Root-relative asset paths meander or our post-processor emits
108+ * (/walkthrough.css, /walkthrough-drag.js, /favicon.ico, etc.).
109+ * 2. Val-Town-shaped part links (/<slug>/part/<n>) — these don't
110+ * exist as files; rewrite to the real flat HTML name
111+ * (walkthrough-part-<n>.html) and prefix with basePath.
112+ *
113+ * We do a narrowly-scoped regex pass rather than a full HTML parse:
114+ * the set of URL attributes we emit is small and known, and we don't
115+ * want to touch hrefs in the prose (external links, anchor jumps).
116+ */
117+ function applyBasePath ( html : string , basePath : string , slug : string ) : string {
118+ if ( ! basePath ) {
119+ return html
120+ }
121+ // 1. Flat part link — rewrite first so step 2 doesn't double-prefix.
122+ // Matches href="/<slug>/part/<n>" and rewrites to
123+ // href="<basePath>/walkthrough-part-<n>.html".
124+ const partLink = new RegExp ( `(href=")/${ slug } /part/(\\d+)/?(")` , 'g' )
125+ let out = html . replace (
126+ partLink ,
127+ ( _m , pre , n , post ) => `${ pre } ${ basePath } /walkthrough-part-${ n } .html${ post } ` ,
128+ )
129+ // 2. Root-relative asset URLs. Match href="/..." and src="/..." and
130+ // ServiceWorker-style register('/...') — but skip:
131+ // - protocol-qualified URLs (https://, data:, mailto:, etc.)
132+ // - anchor/hash links ("#...")
133+ // - URLs that already begin with the basePath (idempotent)
134+ // - the part-link pattern above (already rewritten in step 1)
135+ const assetAttr = / ( \s (?: h r e f | s r c ) = " ) ( \/ [ ^ " ] * ) ( " ) / g
136+ out = out . replace ( assetAttr , ( _m , pre , url , post ) => {
137+ if ( url . startsWith ( basePath + '/' ) || url === basePath ) {
138+ return `${ pre } ${ url } ${ post } `
139+ }
140+ return `${ pre } ${ basePath } ${ url } ${ post } `
141+ } )
142+ // 3. SW register — also prefix. Matches .register('/walkthrough-sw.js').
143+ out = out . replace ( / \. r e g i s t e r \( ' ( \/ [ ^ ' ] + ) ' / g, ( _m , url ) =>
144+ url . startsWith ( basePath + '/' )
145+ ? `.register('${ url } '`
146+ : `.register('${ basePath } ${ url } '` ,
147+ )
148+ return out
149+ }
150+
84151async function generate (
85152 refresh : boolean ,
86153 minify : boolean ,
154+ basePath : string ,
87155 rest : readonly string [ ] ,
88156) : Promise < void > {
89157 if ( rest . length === 0 ) {
90158 console . error (
91- 'Usage: pnpm walkthrough [--refresh] [--minify] generate <walkthrough.json>' ,
159+ 'Usage: pnpm walkthrough [--refresh] [--minify] [--base-path=/prefix] generate <walkthrough.json>' ,
92160 )
93161 process . exit ( 1 )
94162 }
@@ -150,10 +218,12 @@ async function generate(
150218 const configPath = rest [ 0 ]
151219 const walkthroughConfig = configPath
152220 ? ( JSON . parse ( readFileSync ( path . resolve ( configPath ) , 'utf8' ) ) as {
221+ slug ?: string
153222 commentBackend ?: string
154223 } )
155224 : { }
156225 const commentBackend = walkthroughConfig . commentBackend || ''
226+ const slug = walkthroughConfig . slug || ''
157227 if ( commentBackend && existsSync ( commentsSrc ) ) {
158228 copyFileSync (
159229 commentsSrc ,
@@ -259,6 +329,13 @@ async function generate(
259329 html = html . replace ( '</body>' , ` ${ footerTag } \n</body>` )
260330 }
261331
332+ // Base-path rewrite — last step so every injected tag above gets
333+ // prefixed in one pass. No-op when --base-path is empty (local dev,
334+ // Val Town hosting, etc.).
335+ if ( basePath && slug ) {
336+ html = applyBasePath ( html , basePath , slug )
337+ }
338+
262339 writeFileSync ( htmlPath , html )
263340 }
264341
@@ -381,7 +458,7 @@ function routeToFile(slug: string, urlPath: string): string | undefined {
381458 return urlPath . replace ( / ^ \/ / , '' )
382459}
383460
384- function serve ( args : readonly string [ ] ) : void {
461+ function serve ( basePath : string , args : readonly string [ ] ) : void {
385462 const portArg = args . find ( a => a . startsWith ( '--port=' ) )
386463 const port = portArg ? Number ( portArg . slice ( '--port=' . length ) ) : 8080
387464
@@ -396,7 +473,16 @@ function serve(args: readonly string[]): void {
396473
397474 const server = createServer ( ( req , res ) => {
398475 const rawUrl = ( req . url ?? '/' ) . split ( '?' ) [ 0 ] ! . split ( '#' ) [ 0 ] !
399- const decoded = decodeURIComponent ( rawUrl )
476+ let decoded = decodeURIComponent ( rawUrl )
477+ // Strip the base-path prefix so `routeToFile` sees the shape it
478+ // expects. Mirrors the generate-side `--base-path` rewrite so
479+ // `pnpm walkthrough --base-path=/X serve` + a build with the
480+ // same flag round-trips correctly.
481+ if ( basePath && decoded . startsWith ( basePath + '/' ) ) {
482+ decoded = decoded . slice ( basePath . length )
483+ } else if ( basePath && decoded === basePath ) {
484+ decoded = '/'
485+ }
400486 const relative = routeToFile ( slug , decoded )
401487 if ( relative === undefined ) {
402488 res . writeHead ( 400 ) . end ( 'bad request' )
@@ -451,15 +537,27 @@ function main(): void {
451537 const args = process . argv . slice ( 2 )
452538 const refresh = args . includes ( '--refresh' )
453539 const minify = args . includes ( '--minify' )
454- const rest = args . filter ( a => a !== '--refresh' && a !== '--minify' )
540+ // Parse --base-path=<p>. When set, every root-relative asset URL
541+ // and Val-Town-shaped part link is rewritten so the output works
542+ // under a subdirectory host (e.g. GitHub Pages /<repo>/). Leading
543+ // slash enforced, trailing slash stripped so path joins are clean.
544+ const basePathArg = args . find ( a => a . startsWith ( '--base-path=' ) )
545+ const basePath = basePathArg
546+ ? normalizeBasePath ( basePathArg . slice ( '--base-path=' . length ) )
547+ : ''
548+ const rest = args . filter (
549+ a => a !== '--refresh' && a !== '--minify' && ! a . startsWith ( '--base-path=' ) ,
550+ )
455551 const command = rest [ 0 ]
456552
457553 switch ( command ) {
458554 case 'generate' :
459- generate ( refresh , minify , rest . slice ( 1 ) ) . catch ( failWith ( 'generate' ) )
555+ generate ( refresh , minify , basePath , rest . slice ( 1 ) ) . catch (
556+ failWith ( 'generate' ) ,
557+ )
460558 break
461559 case 'serve' :
462- serve ( rest . slice ( 1 ) )
560+ serve ( basePath , rest . slice ( 1 ) )
463561 break
464562 case 'deploy-val' :
465563 deployVal ( rest . slice ( 1 ) ) . catch ( failWith ( 'deploy-val' ) )
@@ -473,7 +571,7 @@ function main(): void {
473571 default :
474572 console . error (
475573 'Usage:\n' +
476- ' pnpm walkthrough [--refresh] [--minify] generate <walkthrough.json>\n' +
574+ ' pnpm walkthrough [--refresh] [--minify] [--base-path=/prefix] generate <walkthrough.json>\n' +
477575 ' pnpm walkthrough serve [--port=8080]\n' +
478576 ' pnpm walkthrough deploy-val [--name=<valname>]\n' +
479577 ' pnpm walkthrough token <set|clear|status>\n' +
0 commit comments