feat: slugify file paths with spaces and special characters (v0.5.1) (#29)

* feat: slugify file paths with spaces and special characters

Apple Notes files (e.g., "2017-05-03 ohmygreen.md") now get clean,
URL-safe slugs instead of raw filenames with spaces. Spaces become
hyphens, special chars are stripped, accented chars normalize to ASCII.

Both import (inferSlug) and sync (pathToSlug) pipelines now use the
same slugifyPath() function, eliminating the case-preservation mismatch.

* feat: one-time migration to slugify existing page slugs

Extends Migration interface with optional TypeScript handler for
application-level data transformations. Adds version 2 migration that
renames all existing slugs to their slugified form, including link
rewriting. Collision handling via try/catch + warning.

* test: slugify unit tests, E2E tests, and updated expectations

22 new unit tests for slugifySegment and slugifyPath covering spaces,
special chars, unicode, dots, empty segments, and all 4 bug report
examples. Updated pathToSlug tests for new lowercase behavior. Updated
E2E tests for slugified Apple Notes slugs. Added 2 new E2E tests for
space-named file import and sync.

* chore: bump version and changelog (v0.5.1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix changelog example to show directory with spaces

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-10 11:42:32 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e9f3c9c24d
commit 27eb87f1f4
9 changed files with 259 additions and 42 deletions
+2 -11
View File
@@ -1,5 +1,6 @@
import matter from 'gray-matter';
import type { PageType } from './types.ts';
import { slugifyPath } from './sync.ts';
export interface ParsedMarkdown {
frontmatter: Record<string, unknown>;
@@ -148,17 +149,7 @@ function inferTitle(filePath?: string): string {
function inferSlug(filePath?: string): string {
if (!filePath) return 'untitled';
// Remove leading path components that are just the import root
// Keep the type directory + filename structure
let slug = filePath
.replace(/\.md$/i, '')
.replace(/\\/g, '/');
// Remove leading ./
if (slug.startsWith('./')) slug = slug.slice(2);
return slug.toLowerCase();
return slugifyPath(filePath);
}
function extractTags(frontmatter: Record<string, unknown>): string[] {
+46 -13
View File
@@ -1,4 +1,5 @@
import type { BrainEngine } from './engine.ts';
import { slugifyPath } from './sync.ts';
/**
* Schema migrations — run automatically on initSchema().
@@ -8,20 +9,45 @@ import type { BrainEngine } from './engine.ts';
*
* Each migration runs in a transaction: if the SQL fails, the version stays
* where it was and the next run retries cleanly.
*
* Migrations can also include a handler function for application-level logic
* (e.g., data transformations that need TypeScript, not just SQL).
*/
interface Migration {
version: number;
name: string;
sql: string;
handler?: (engine: BrainEngine) => Promise<void>;
}
// Migrations are embedded here, not loaded from files.
// Add new migrations at the end. Never modify existing ones.
const MIGRATIONS: Migration[] = [
// Version 1 is the baseline (schema.sql creates everything with IF NOT EXISTS).
// Future migrations go here:
// { version: 2, name: 'add_aliases', sql: `ALTER TABLE pages ADD COLUMN IF NOT EXISTS aliases TEXT[];` },
{
version: 2,
name: 'slugify_existing_pages',
sql: '',
handler: async (engine) => {
const pages = await engine.listPages();
let renamed = 0;
for (const page of pages) {
const newSlug = slugifyPath(page.slug);
if (newSlug !== page.slug) {
try {
await engine.updateSlug(page.slug, newSlug);
await engine.rewriteLinks(page.slug, newSlug);
renamed++;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: could not rename "${page.slug}" → "${newSlug}": ${msg}`);
}
}
}
if (renamed > 0) console.log(` Renamed ${renamed} slugs`);
},
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
@@ -35,17 +61,24 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
let applied = 0;
for (const m of MIGRATIONS) {
if (m.version > current) {
// Each migration is transactional
await engine.transaction(async (tx) => {
// Execute the migration SQL via raw connection
// We need to access the underlying sql connection
const eng = tx as any;
const sql = eng.sql || eng._sql;
if (sql) {
await sql.unsafe(m.sql);
await sql`UPDATE config SET value = ${String(m.version)} WHERE key = 'version'`;
}
});
// SQL migration (transactional)
if (m.sql) {
await engine.transaction(async (tx) => {
const eng = tx as any;
const sql = eng.sql || eng._sql;
if (sql) {
await sql.unsafe(m.sql);
}
});
}
// Application-level handler (runs outside transaction for flexibility)
if (m.handler) {
await m.handler(engine);
}
// Update version after both SQL and handler succeed
await engine.setConfig('version', String(m.version));
console.log(` Migration ${m.version} applied: ${m.name}`);
applied++;
}
+29 -11
View File
@@ -97,21 +97,39 @@ export function isSyncable(path: string): boolean {
}
/**
* Convert a repo-relative file path to a GBrain page slug.
* Slugify a single path segment: lowercase, strip special chars, spaces → hyphens.
*/
export function slugifySegment(segment: string): string {
return segment
.normalize('NFD') // Decompose accented chars
.replace(/[\u0300-\u036f]/g, '') // Strip accent marks
.toLowerCase()
.replace(/[^a-z0-9.\s_-]/g, '') // Keep alphanumeric, dots, spaces, underscores, hyphens
.replace(/[\s]+/g, '-') // Spaces → hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Strip leading/trailing hyphens
}
/**
* Slugify a file path: strip .md, normalize separators, slugify each segment.
*
* Examples:
* people/pedro-franceschi.md → people/pedro-franceschi
* daily/2026-04-05.md → daily/2026-04-05
* notes.md → notes
* Apple Notes/2017-05-03 ohmygreen.md → apple-notes/2017-05-03-ohmygreen
* people/alice-smith.md → people/alice-smith
* notes/v1.0.0.md → notes/v1.0.0
*/
export function slugifyPath(filePath: string): string {
let path = filePath.replace(/\.md$/i, '');
path = path.replace(/\\/g, '/');
path = path.replace(/^\.?\//, '');
return path.split('/').map(slugifySegment).filter(Boolean).join('/');
}
/**
* Convert a repo-relative file path to a GBrain page slug.
*/
export function pathToSlug(filePath: string, repoPrefix?: string): string {
// Strip .md extension
let slug = filePath.replace(/\.md$/, '');
// Normalize separators
slug = slug.replace(/\\/g, '/');
// Strip leading slash
slug = slug.replace(/^\//, '');
// Add repo prefix for multi-repo setups
let slug = slugifyPath(filePath);
if (repoPrefix) slug = `${repoPrefix}/${slug}`;
return slug;
}