Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/core/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 33 additions & 3 deletions packages/core/src/engine/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down
39 changes: 23 additions & 16 deletions packages/core/src/utils/auto-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading