diff --git a/packages/core/src/commands/dev.ts b/packages/core/src/commands/dev.ts index f2b5f83..985fad8 100644 --- a/packages/core/src/commands/dev.ts +++ b/packages/core/src/commands/dev.ts @@ -101,6 +101,12 @@ export async function startDevServer(configPathOption: string, opts: any = {}) { try { const workerScript = path.resolve(__dirname, '../engine/worker-parser.js'); workerPool = new WorkerPool(workerScript, { config, cwd: CWD }); + // Clean output dir before initial build to remove stale files from previous builds. + // Without this, files generated under old URL structures (e.g. from a different + // auto-router behaviour) persist alongside new files and cause 404 on nav links. + if (await fs.exists(rootOutputDir)) { + await fs.remove(rootOutputDir); + } await buildSite(configPathOption, { isDev: true, preserve: options.preserve, quiet: true, showStats: false, workerPool }); TUI.info(`Initial build completed in ${initialElapsed()}.`); } catch (error: any) { @@ -173,7 +179,10 @@ export async function startDevServer(configPathOption: string, opts: any = {}) { isDev: true, preserve: options.preserve, quiet: true, - targetFiles: [filePath], + // No targetFiles → full rebuild on every change. + // A targeted rebuild only regenerates the changed file, leaving the + // navigation HTML in all other pages stale. A full rebuild is ~250ms + // for typical doc projects — negligible for dev ergonomics. workerPool }); sp.done(`Rebuilt: ${relativeFilePath} in ${rebuildElapsed()}`, true); diff --git a/packages/core/src/engine/generator.ts b/packages/core/src/engine/generator.ts index 99ef7a5..5a68802 100644 --- a/packages/core/src/engine/generator.ts +++ b/packages/core/src/engine/generator.ts @@ -16,6 +16,29 @@ import path from 'path'; import { fsUtils as fs } from '@docmd/utils'; import { createRequire } from 'module'; import { execSync } from 'child_process'; + +/** + * Convert every segment of a relative file path to a URL-safe slug, + * mirroring the slugifySegment logic in auto-router.ts. + * This ensures the output path written to disk matches the URL that + * buildAutoNav generates for the same file. + * + * Example: "SA/folder with space/page with space" + * → "SA/folder-with-space/page-with-space" + */ +function slugifyOutputPath(p: string): string { + return p + .split('/') + .map(seg => + seg + .replace(/\s+/g, '-') + .replace(/[^a-zA-Z0-9\-_.~]/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + || seg + ) + .join('/'); +} import { generateAssetTag, findFilesRecursive } from './assets.js'; import { generateHreflangTags } from './i18n.js'; import nativeFs from 'fs'; @@ -357,10 +380,17 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h const processed = await parser.processContentAsync(rawContent, mdProcessor, config, { isIndex: effectivelyIndex }, hooks); if (!processed) continue; - // Determine output path - const withoutExt = relativePath.replace(/\.(md|markdown|ejs)$/, ''); + // Determine output path — slugify each path segment so that spaces and + // URL-unsafe characters are replaced with hyphens, matching the URLs + // generated by buildAutoNav in auto-router.ts. + const withoutExt = slugifyOutputPath( + relativePath.replace(/\.(md|markdown|ejs)$/, '').replace(/\\/g, '/') + ); + const slugifiedDir = slugifyOutputPath( + path.dirname(relativePath).replace(/\\/g, '/') + ); const htmlOutputPath = effectivelyIndex - ? path.posix.join(outputPrefix, path.dirname(relativePath), 'index.html').replace(/^\/?/, '') + ? path.posix.join(outputPrefix, slugifiedDir, 'index.html').replace(/^\/?/, '') : path.posix.join(outputPrefix, withoutExt, 'index.html').replace(/^\/?/, ''); pages.push({ ...processed, sourcePath: targetFilePath, outputPath: htmlOutputPath }); } diff --git a/packages/core/src/utils/auto-router.ts b/packages/core/src/utils/auto-router.ts index 4e89d74..1aa700d 100644 --- a/packages/core/src/utils/auto-router.ts +++ b/packages/core/src/utils/auto-router.ts @@ -16,6 +16,25 @@ import fs from 'fs'; import path from 'path'; import { normalizeInternalHref } from '@docmd/parser'; +/** + * Convert a file/folder name to a URL-safe slug. + * Replaces spaces and URL-unsafe characters with hyphens so that the + * generated nav link, the output file path, and the browser URL all agree. + * + * Examples: + * "folder with space" → "folder-with-space" + * "my file (draft)" → "my-file-draft" + * "already-slug_ok" → "already-slug_ok" (no change) + */ +function slugifySegment(name: string): string { + return name + .replace(/\s+/g, '-') // spaces → hyphens + .replace(/[^a-zA-Z0-9\-_.~]/g, '-') // unsafe URL chars → hyphens + .replace(/-{2,}/g, '-') // collapse consecutive hyphens + .replace(/^-+|-+$/g, '') // strip leading/trailing hyphens + || name; // fallback: keep original if result is empty +} + // Extract title from Frontmatter or H1 without loading heavy parsers function extractTitleFromFile(filePath: string, filename: string) { try { @@ -77,10 +96,11 @@ export function buildAutoNav(dir: string, basePath = '/'): any[] { // Default ba const fullPath = path.join(dir, item.name); - // Construct URL path: basePath + filename - // Ensure we don't double slash if basePath is '/' + // Construct URL path: basePath + slugified filename. + // Slugify so that spaces and URL-unsafe characters are replaced with hyphens, + // keeping the nav link, output file path, and browser URL consistent. const safeBase = basePath.endsWith('/') ? basePath : basePath + '/'; - const relPath = safeBase + item.name; + const relPath = safeBase + slugifySegment(item.name); if (item.isDirectory()) { const children = buildAutoNav(fullPath, relPath); @@ -111,19 +131,6 @@ export function buildAutoNav(dir: string, basePath = '/'): any[] { // Default ba } } - // If no index exists at this level, and we have files, designate the first one as index - if (!indexExists && nav.length > 0) { - // Find first file (not a folder) - alphabetical after sorting (sorted below) - // But since sort hasn't happened yet, sort copies to pick the true first - const fileItems = nav.filter(n => !n.children).sort((a, b) => a.title.localeCompare(b.title)); - if (fileItems.length > 0) { - const firstFile = fileItems[0]; - // Store the original path before reassigning so the generator knows which file this is - firstFile._sourceFile = firstFile.path; - firstFile.path = basePath === '/' ? '/' : basePath; - } - } - // Sort: Put index.md (Home) at the top, then sort alphabetically return nav.sort((a, b) => { // Check if path effectively points to current folder root