diff --git a/CHANGELOG.md b/CHANGELOG.md index 713adef..9eeef22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [Unreleased] + +### Deprecated + +- **kamado:** `kamado/features` module is now deprecated and will be removed in v2.0.0 + - `getBreadcrumbs` - use `@kamado-io/page-compiler` instead + - `getNavTree` - use `@kamado-io/page-compiler` instead + - `titleList` - use `@kamado-io/page-compiler` instead + - `getTitle` - use `@kamado-io/page-compiler` instead + - `getTitleFromStaticFile` - use `@kamado-io/page-compiler` instead + # [1.1.0](https://github.com/d-zero-dev/kamado/compare/v1.0.0...v1.1.0) (2026-01-07) ### Bug Fixes diff --git a/MILESTONE.md b/MILESTONE.md new file mode 100644 index 0000000..daf496a --- /dev/null +++ b/MILESTONE.md @@ -0,0 +1,18 @@ +# Milestone: v2.0.0 + +## Breaking Changes TODO + +- [x] `kamado/features` エクスポートを削除 + - [x] `getBreadcrumbs` を `@kamado-io/page-compiler` 内部に移動 + - [x] `getNavTree` を `@kamado-io/page-compiler` 内部に移動 + - [x] `getTitleList` を `@kamado-io/page-compiler` 内部に移動 + - [x] `getTitle` を `@kamado-io/page-compiler` 内部に移動 + - [x] `getTitleFromStaticFile` を `@kamado-io/page-compiler` 内部に移動 + - [x] `kamado/features` に deprecation 警告を追加(v2.0.0 で削除予定) + - [ ] `CompilableFile` 型の再設計を検討 + +## Migration Guide + +`kamado/features` は v2.0.0 で削除されます。これらの機能は `@kamado-io/page-compiler` 内部で自動的に使用されるため、直接インポートする必要はありません。 + +カスタマイズが必要な場合は、`PageCompilerOptions` の `transformBreadcrumbItem` および `transformNavNode` オプションを使用してください。詳細は [@kamado-io/page-compiler の README](./packages/@kamado-io/page-compiler/README.md) を参照してください。 diff --git a/packages/@kamado-io/page-compiler/README.md b/packages/@kamado-io/page-compiler/README.md index 2e2c3e7..80343ec 100644 --- a/packages/@kamado-io/page-compiler/README.md +++ b/packages/@kamado-io/page-compiler/README.md @@ -49,6 +49,8 @@ export const config: UserConfig = { - `lineBreak`: Line break configuration (`'\n'` or `'\r\n'`) - `characterEntities`: Whether to enable character entity conversion - `optimizeTitle`: Function to optimize titles +- `transformBreadcrumbItem`: Function to transform each breadcrumb item. Can add custom properties to breadcrumb items. `(item: BreadcrumbItem) => BreadcrumbItem` +- `transformNavNode`: Function to transform each navigation node. Can add custom properties or filter nodes by returning `null`/`undefined`. `(node: NavNode) => NavNode | null | undefined` - `host`: Host URL for JSDOM's url option. If not specified, in build mode uses `production.baseURL` or `production.host` from package.json, in serve mode uses dev server URL (`http://${devServer.host}:${devServer.port}`) - `beforeSerialize`: Hook function called before DOM serialization `(content: string, isServe: boolean) => Promise | string` - `afterSerialize`: Hook function called after DOM serialization `(elements: readonly Element[], window: Window, isServe: boolean) => Promise | void` diff --git a/packages/@kamado-io/page-compiler/src/features/breadcrumbs.spec.ts b/packages/@kamado-io/page-compiler/src/features/breadcrumbs.spec.ts new file mode 100644 index 0000000..b50c818 --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/breadcrumbs.spec.ts @@ -0,0 +1,203 @@ +import type { CompilableFile } from 'kamado/files'; + +import { describe, test, expect } from 'vitest'; + +import { getBreadcrumbs } from './breadcrumbs.js'; + +/** + * Creates a mock CompilableFile for testing + * The filePathStem is derived from URL to match the isAncestor logic: + * - `/` → `/index` (index file at root) + * - `/about/` → `/about/index` (index file in about directory) + * - `/about/team/` → `/about/team/index` + * @param url - URL path ending with `/` + * @param title - Page title + * @param metaData - Optional metadata (front matter) + */ +function createMockPage( + url: string, + title: string, + metaData: Record = {}, +): CompilableFile & { title: string } { + // Convert URL to filePathStem matching isAncestor logic + // /about/ → /about/index, / → /index + const filePathStem = url === '/' ? '/index' : url.replace(/\/$/, '/index'); + const slug = url === '/' ? 'index' : url.split('/').findLast(Boolean) || 'index'; + + return { + inputPath: `/mock/input${filePathStem}.html`, + outputPath: `/mock/output${filePathStem}.html`, + fileSlug: slug, + filePathStem, + url, + extension: '.html', + date: new Date(), + title, + get: () => + Promise.resolve({ + metaData, + content: '

content

', + raw: '

content

', + }), + }; +} + +describe('getBreadcrumbs', () => { + describe('basic functionality', () => { + test('should return breadcrumbs', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const pageList = [indexPage, aboutPage]; + + const breadcrumbs = getBreadcrumbs(aboutPage, pageList); + + expect(breadcrumbs).toHaveLength(2); + expect(breadcrumbs[0]?.title).toBe('Home'); + expect(breadcrumbs[0]?.href).toBe('/'); + expect(breadcrumbs[0]?.depth).toBe(0); + expect(breadcrumbs[1]?.title).toBe('About'); + expect(breadcrumbs[1]?.href).toBe('/about/'); + expect(breadcrumbs[1]?.depth).toBe(1); + }); + + test('should handle deep hierarchy (3+ levels)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const teamPage = createMockPage('/about/team/', 'Team'); + const memberPage = createMockPage('/about/team/member/', 'Member'); + const pageList = [indexPage, aboutPage, teamPage, memberPage]; + + const breadcrumbs = getBreadcrumbs(memberPage, pageList); + + expect(breadcrumbs).toHaveLength(4); + expect(breadcrumbs.map((b) => b.href)).toEqual([ + '/', + '/about/', + '/about/team/', + '/about/team/member/', + ]); + expect(breadcrumbs.map((b) => b.depth)).toEqual([0, 1, 2, 3]); + }); + + test('should return sorted by depth', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const teamPage = createMockPage('/about/team/', 'Team'); + // Pass in shuffled order + const pageList = [teamPage, indexPage, aboutPage]; + + const breadcrumbs = getBreadcrumbs(teamPage, pageList); + + expect(breadcrumbs.map((b) => b.href)).toEqual(['/', '/about/', '/about/team/']); + }); + }); + + describe('baseURL option', () => { + test('should filter breadcrumbs by baseURL', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const teamPage = createMockPage('/about/team/', 'Team'); + const pageList = [indexPage, aboutPage, teamPage]; + + const breadcrumbs = getBreadcrumbs(teamPage, pageList, { + baseURL: '/about/', + }); + + // Should exclude items with depth < 1 (baseURL depth) + expect(breadcrumbs).toHaveLength(2); + expect(breadcrumbs.map((b) => b.href)).toEqual(['/about/', '/about/team/']); + }); + + test('should handle root baseURL (default)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const pageList = [indexPage, aboutPage]; + + const breadcrumbs = getBreadcrumbs(aboutPage, pageList, { + baseURL: '/', + }); + + expect(breadcrumbs).toHaveLength(2); + }); + }); + + describe('transformItem option', () => { + test('should apply transformItem to add custom properties', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const pageList = [indexPage, aboutPage]; + + const iconMap: Record = { + '/': 'home-icon', + '/about/': 'about-icon', + }; + + const breadcrumbs = getBreadcrumbs(aboutPage, pageList, { + transformItem: (item) => ({ + ...item, + icon: iconMap[item.href] ?? 'default', + }), + }); + + expect(breadcrumbs).toHaveLength(2); + expect(breadcrumbs[0]).toMatchObject({ + title: 'Home', + href: '/', + icon: 'home-icon', + }); + expect(breadcrumbs[1]).toMatchObject({ + title: 'About', + href: '/about/', + icon: 'about-icon', + }); + }); + + test('should propagate error from transformItem', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const pageList = [indexPage, aboutPage]; + + expect(() => + getBreadcrumbs(aboutPage, pageList, { + transformItem: () => { + throw new Error('Transform error'); + }, + }), + ).toThrow('Transform error'); + }); + }); + + describe('edge cases', () => { + test('should return only current page when it is the root', () => { + const indexPage = createMockPage('/', 'Home'); + const pageList = [indexPage]; + + const breadcrumbs = getBreadcrumbs(indexPage, pageList); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.href).toBe('/'); + }); + + test('should return empty array when pageList is empty', () => { + const aboutPage = createMockPage('/about/', 'About'); + const pageList: (CompilableFile & { title: string })[] = []; + + const breadcrumbs = getBreadcrumbs(aboutPage, pageList); + + expect(breadcrumbs).toHaveLength(0); + }); + + test('should return only matching ancestors', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const contactPage = createMockPage('/contact/', 'Contact'); // Not an ancestor + const teamPage = createMockPage('/about/team/', 'Team'); + const pageList = [indexPage, aboutPage, contactPage, teamPage]; + + const breadcrumbs = getBreadcrumbs(teamPage, pageList); + + // /contact/ should not be included + expect(breadcrumbs.map((b) => b.href)).toEqual(['/', '/about/', '/about/team/']); + }); + }); +}); diff --git a/packages/@kamado-io/page-compiler/src/features/breadcrumbs.ts b/packages/@kamado-io/page-compiler/src/features/breadcrumbs.ts new file mode 100644 index 0000000..6e332d0 --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/breadcrumbs.ts @@ -0,0 +1,121 @@ +import type { CompilableFile } from 'kamado/files'; + +import path from 'node:path'; + +import { getTitle } from './title.js'; + +/** + * Breadcrumb item + */ +export type BreadcrumbItem = { + /** + * Title + */ + readonly title: string | undefined; + /** + * Link URL + */ + readonly href: string; + /** + * Hierarchy depth + */ + readonly depth: number; +}; + +/** + * Options for getting breadcrumbs + * @template TOut - Type of additional properties added by transformItem + */ +export type GetBreadcrumbsOptions< + TOut extends Record = Record, +> = { + /** + * Base URL + * @default '/' + */ + readonly baseURL?: string; + /** + * Function to optimize titles + */ + readonly optimizeTitle?: (title: string) => string; + /** + * Transform each breadcrumb item + * @param item - Original breadcrumb item + * @returns Transformed breadcrumb item with additional properties + */ + readonly transformItem?: (item: BreadcrumbItem) => BreadcrumbItem & TOut; +}; + +/** + * Gets breadcrumb list for a page + * @template TOut - Type of additional properties added by transformItem + * @param page - Target page file + * @param pageList - List of all page files + * @param options - Options for getting breadcrumbs + * @returns Array of breadcrumb items (with additional properties if transformItem is specified) + * @example + * ```typescript + * const breadcrumbs = getBreadcrumbs(currentPage, pageList, { + * baseURL: '/', + * optimizeTitle: (title) => title.trim(), + * }); + * ``` + * @example + * ```typescript + * // With transformItem for adding custom properties + * const breadcrumbs = getBreadcrumbs(currentPage, pageList, { + * transformItem: (item) => ({ + * ...item, + * icon: item.href === '/' ? 'home' : 'page', + * }), + * }); + * ``` + */ +export function getBreadcrumbs< + TOut extends Record = Record, +>( + page: CompilableFile & { title?: string }, + pageList: readonly (CompilableFile & { title?: string })[], + options?: GetBreadcrumbsOptions, +): (BreadcrumbItem & TOut)[] { + const baseURL = options?.baseURL ?? '/'; + const optimizeTitle = options?.optimizeTitle; + const baseDepth = baseURL.split('/').filter(Boolean).length; + const pages = pageList.filter((item) => + isAncestor(page.filePathStem, item.filePathStem), + ); + const breadcrumbs = pages.map((sourcePage) => ({ + title: + sourcePage.title?.trim() || + getTitle(sourcePage, optimizeTitle, true) || + '__NO_TITLE__', + href: sourcePage.url, + depth: sourcePage.url.split('/').filter(Boolean).length, + })); + + const filtered = breadcrumbs + .filter((item) => item.depth >= baseDepth) + .toSorted((a, b) => a.depth - b.depth); + + // Apply transformItem if specified + if (options?.transformItem) { + return filtered.map((item) => options.transformItem!(item as BreadcrumbItem)); + } + + return filtered as (BreadcrumbItem & TOut)[]; +} + +/** + * Checks if target path is an ancestor of base path + * @param basePagePathStem - Base page path stem + * @param targetPathStem - Target path stem to check + * @returns True if target is an ancestor (index file) or the same path + */ +function isAncestor(basePagePathStem: string, targetPathStem: string) { + const dirname = path.dirname(targetPathStem); + const name = path.basename(targetPathStem); + const included = dirname === '/' || basePagePathStem.startsWith(dirname + '/'); + const isIndex = name === 'index'; + const isSelf = basePagePathStem === targetPathStem; + return (included && isIndex) || isSelf; +} diff --git a/packages/@kamado-io/page-compiler/src/features/nav.spec.ts b/packages/@kamado-io/page-compiler/src/features/nav.spec.ts new file mode 100644 index 0000000..fed5c20 --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/nav.spec.ts @@ -0,0 +1,907 @@ +import type { CompilableFile } from 'kamado/files'; + +import { describe, test, expect } from 'vitest'; + +import { getNavTree, type NavNode } from './nav.js'; + +/** + * Creates a mock CompilableFile for testing + * The filePathStem is derived from URL to match directory structure: + * - `/` → `/index` + * - `/about/` → `/about/index` + * @param url - URL path ending with `/` + * @param title - Page title + * @param metaData - Optional metadata (front matter) + */ +function createMockPage( + url: string, + title: string, + metaData: Record = {}, +): CompilableFile & { title: string } { + const filePathStem = url === '/' ? '/index' : url.replace(/\/$/, '/index'); + const slug = url === '/' ? 'index' : url.split('/').findLast(Boolean) || 'index'; + + return { + inputPath: `/mock/input${filePathStem}.html`, + outputPath: `/mock/output${filePathStem}.html`, + fileSlug: slug, + filePathStem, + url, + extension: '.html', + date: new Date(), + title, + get: () => + Promise.resolve({ + metaData, + content: '

content

', + raw: '

content

', + }), + }; +} + +/** + * Recursively counts all nodes in a tree + * @param node + */ +function countNodes(node: NavNode | null): number { + if (!node) return 0; + return 1 + node.children.reduce((sum, child) => sum + countNodes(child as NavNode), 0); +} + +/** + * Recursively finds a node by URL in the tree + * @param node + * @param url + */ +function findNodeByUrl(node: NavNode | null, url: string): NavNode | null { + if (!node) return null; + if (node.url === url) return node; + for (const child of node.children) { + const found = findNodeByUrl(child as NavNode, url); + if (found) return found; + } + return null; +} + +describe('getNavTree', () => { + /** + * Level structure (depth in parentheses): + * / (depth 0) - level 1 + * /about/ (depth 1) - level 2 + * /about/history/ (depth 2) - level 3 + * /about/history/2025/ (depth 3) - level 4 + * + * getNavTree default behavior (without baseDepth option): + * - Returns the ancestor node at (current page's depth - 1) + * - For /about/history/2025/ (depth 3), returns /about/history/ (depth 2) + * - Can be customized with baseDepth option + */ + + describe('basic functionality', () => { + test('should return nav tree', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025'); + const pageList = [indexPage, aboutPage, aboutHistoryPage, aboutHistory2025Page]; + + // Current page is /about/history/2025/ (depth 3, level 4) + // getNavTree returns the depth 2 (level 3) ancestor: /about/history/ + const navTree = getNavTree(aboutHistory2025Page, pageList); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/history/'); + expect(navTree?.title).toBe('History'); + }); + + test('should include children in the returned tree', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025'); + const aboutHistory2024Page = createMockPage('/about/history/2024/', '2024'); + const pageList = [ + indexPage, + aboutPage, + aboutHistoryPage, + aboutHistory2025Page, + aboutHistory2024Page, + ]; + + const navTree = getNavTree(aboutHistory2025Page, pageList); + + expect(navTree).not.toBeNull(); + expect(navTree?.children).toHaveLength(2); + expect(navTree?.children.map((c) => c.url).toSorted()).toEqual([ + '/about/history/2024/', + '/about/history/2025/', + ]); + }); + + test('should return the page itself when at depth 2 with baseDepth option', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, aboutHistoryPage]; + + // Current page is /about/history/ (level 3, depth 1) + const navTree = getNavTree(aboutHistoryPage, pageList, { + baseDepth: 1, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/'); + }); + }); + + describe('transformNode option', () => { + test('should apply transformNode synchronously', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025'); + const pageList = [indexPage, aboutPage, aboutHistoryPage, aboutHistory2025Page]; + + const badgeMap: Record = { + '/about/history/': 'section', + '/about/history/2025/': 'new', + }; + + const navTree = getNavTree(aboutHistory2025Page, pageList, { + transformNode: (node) => ({ + ...node, + badge: badgeMap[node.url] ?? 'default', + }), + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/history/'); + expect((navTree as NavNode & { badge: string }).badge).toBe('section'); + + // Check children nodes are also transformed + const child2025 = navTree?.children.find((c) => c.url === '/about/history/2025/'); + expect(child2025).toBeDefined(); + expect((child2025 as NavNode & { badge: string }).badge).toBe('new'); + }); + + test('should apply transformNode asynchronously with page.get()', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History', { + badge: 'history-badge', + }); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025', { + badge: '2025-badge', + }); + const pageList = [indexPage, aboutPage, aboutHistoryPage, aboutHistory2025Page]; + + const navTree = getNavTree<{ badge: string | undefined }>( + aboutHistory2025Page, + pageList, + { + transformNode: (node) => { + return { + ...node, + badge: 'new', + }; + }, + }, + ); + + expect(navTree).not.toBeNull(); + expect(navTree?.badge).toBe('new'); + + const child2025 = navTree?.children.find((c) => c.url === '/about/history/2025/'); + expect((child2025 as NavNode & { badge: string }).badge).toBe('new'); + }); + + test('should transform all nodes recursively in deep hierarchy', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const y2025Page = createMockPage('/about/history/2025/', '2025'); + const janPage = createMockPage('/about/history/2025/jan/', 'January'); + const pageList = [indexPage, aboutPage, historyPage, y2025Page, janPage]; + + let transformCount = 0; + const navTree = getNavTree(janPage, pageList, { + transformNode: (node) => { + transformCount++; + return { ...node, transformed: true }; + }, + }); + + expect(navTree).not.toBeNull(); + // All nodes in the subtree should be transformed + const totalNodes = countNodes(navTree!); + expect(transformCount).toBe(totalNodes); + + // Verify deep child is transformed + const janNode = findNodeByUrl(navTree!, '/about/history/2025/jan/'); + expect(janNode).not.toBeNull(); + expect((janNode as NavNode & { transformed: boolean }).transformed).toBe(true); + }); + + test('should propagate error from transformNode', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + expect(() => + getNavTree(historyPage, pageList, { + transformNode: () => { + throw new Error('Transform error'); + }, + }), + ).toThrow('Transform error'); + }); + }); + + describe('backward compatibility', () => { + test('should work without transformNode', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, aboutHistoryPage]; + + const navTree = getNavTree(aboutHistoryPage, pageList, { + baseDepth: 1, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.title).toBe('About'); + expect(navTree?.url).toBe('/about/'); + }); + + test('should work with empty options object', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + const navTree = getNavTree(historyPage, pageList); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/'); + }); + }); + + describe('edge cases', () => { + test('should handle single page at level 3', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + expect( + getNavTree(historyPage, pageList, { + baseDepth: 2, + })?.url, + ).toBeUndefined(); + + expect( + getNavTree(historyPage, pageList, { + baseDepth: 1, + })?.url, + ).toBe('/about/'); + + expect( + getNavTree(historyPage, pageList, { + baseDepth: 0, + })?.url, + ).toBe('/'); + }); + }); + + describe('removed nodes', () => { + test('should remove nodes when transformNode returns null', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + if (node.url === '/about/') { + return null; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + children: [], + current: true, + depth: 0, + isAncestor: false, + stem: '/', + title: 'Home', + url: '/', + }); + }); + + test('should remove nodes when transformNode returns undefined', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + if (node.url === '/about/') { + return; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + children: [], + current: true, + depth: 0, + isAncestor: false, + stem: '/', + title: 'Home', + url: '/', + }); + }); + + test('should remove parent node and all its descendants when parent returns null', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const history2025JanPage = createMockPage('/about/history/2025/jan/', 'January'); + const contactPage = createMockPage('/contact/', 'Contact'); + const pageList = [ + indexPage, + aboutPage, + historyPage, + history2025Page, + history2025JanPage, + contactPage, + ]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + // Remove /about/ and all its descendants should disappear + if (node.url === '/about/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/'); + expect(navTree?.children).toHaveLength(1); + expect(navTree?.children[0]?.url).toBe('/contact/'); + + // Verify that all /about/ descendants are gone from the entire tree + expect(findNodeByUrl(navTree!, '/about/')).toBeNull(); + expect(findNodeByUrl(navTree!, '/about/history/')).toBeNull(); + expect(findNodeByUrl(navTree!, '/about/history/2025/')).toBeNull(); + expect(findNodeByUrl(navTree!, '/about/history/2025/jan/')).toBeNull(); + }); + + test('should not call transformNode for descendants when parent returns null', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const pageList = [indexPage, aboutPage, historyPage, history2025Page]; + + const calledUrls: string[] = []; + + getNavTree(indexPage, pageList, { + transformNode: (node) => { + calledUrls.push(node.url); + if (node.url === '/about/') { + return null; + } + return node; + }, + }); + + // transformNode is called bottom-up (children first, then parent) + // When /about/ returns null, its children have already been processed + // but the entire subtree is removed from the result + expect(calledUrls).toContain('/'); + expect(calledUrls).toContain('/about/'); + // Children are processed before parent + expect(calledUrls).toContain('/about/history/'); + expect(calledUrls).toContain('/about/history/2025/'); + }); + + test('should remove child nodes from parent when transformNode returns null', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025'); + const aboutHistory2024Page = createMockPage('/about/history/2024/', '2024'); + const pageList = [ + indexPage, + aboutPage, + aboutHistoryPage, + aboutHistory2025Page, + aboutHistory2024Page, + ]; + + const navTree = getNavTree(aboutHistory2025Page, pageList, { + transformNode: (node) => { + if (node.url === '/about/history/2024/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/history/'); + expect(navTree?.children).toHaveLength(1); + expect(navTree?.children[0]?.url).toBe('/about/history/2025/'); + // Verify removed node is not findable anywhere + expect(findNodeByUrl(navTree!, '/about/history/2024/')).toBeNull(); + }); + + test('should remove multiple child nodes when transformNode returns null or undefined', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const aboutHistoryPage = createMockPage('/about/history/', 'History'); + const aboutHistory2025Page = createMockPage('/about/history/2025/', '2025'); + const aboutHistory2024Page = createMockPage('/about/history/2024/', '2024'); + const aboutHistory2023Page = createMockPage('/about/history/2023/', '2023'); + const pageList = [ + indexPage, + aboutPage, + aboutHistoryPage, + aboutHistory2025Page, + aboutHistory2024Page, + aboutHistory2023Page, + ]; + + const navTree = getNavTree(aboutHistory2025Page, pageList, { + transformNode: (node) => { + if (node.url === '/about/history/2024/') { + return null; + } + if (node.url === '/about/history/2023/') { + return; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.url).toBe('/about/history/'); + expect(navTree?.children).toHaveLength(1); + expect(navTree?.children[0]?.url).toBe('/about/history/2025/'); + // Both removed nodes should not exist anywhere + expect(findNodeByUrl(navTree!, '/about/history/2024/')).toBeNull(); + expect(findNodeByUrl(navTree!, '/about/history/2023/')).toBeNull(); + }); + + test('should result in empty children when all child nodes are removed', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const teamPage = createMockPage('/about/team/', 'Team'); + const pageList = [indexPage, aboutPage, historyPage, teamPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + // Remove all children of /about/ + if (node.url === '/about/history/' || node.url === '/about/team/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + const aboutNode = findNodeByUrl(navTree!, '/about/'); + expect(aboutNode).not.toBeNull(); + expect(aboutNode?.children).toHaveLength(0); + expect(aboutNode?.children).toStrictEqual([]); + }); + + test('should not affect sibling nodes when one sibling is removed', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const contactPage = createMockPage('/contact/', 'Contact'); + const servicesPage = createMockPage('/services/', 'Services'); + const pageList = [indexPage, aboutPage, contactPage, servicesPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + if (node.url === '/contact/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.children).toHaveLength(2); + const urls = navTree?.children.map((c) => c.url).toSorted(); + expect(urls).toEqual(['/about/', '/services/']); + // Verify each sibling still has correct properties + const aboutNode = findNodeByUrl(navTree!, '/about/'); + const servicesNode = findNodeByUrl(navTree!, '/services/'); + expect(aboutNode?.title).toBe('About'); + expect(servicesNode?.title).toBe('Services'); + }); + + test('should remove grandchild while keeping parent and child intact', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const history2024Page = createMockPage('/about/history/2024/', '2024'); + const pageList = [ + indexPage, + aboutPage, + historyPage, + history2025Page, + history2024Page, + ]; + + const navTree = getNavTree(history2025Page, pageList, { + transformNode: (node) => { + // Remove only a grandchild (2024) + if (node.url === '/about/history/2024/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + // Parent (/about/history/) should exist + expect(navTree?.url).toBe('/about/history/'); + expect(navTree?.title).toBe('History'); + // Only one child should remain + expect(navTree?.children).toHaveLength(1); + expect(navTree?.children[0]?.url).toBe('/about/history/2025/'); + // Grandchild should be gone + expect(findNodeByUrl(navTree!, '/about/history/2024/')).toBeNull(); + }); + + test('should return null when root node is removed by transformNode', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: () => null, + }); + + expect(navTree).toBeNull(); + }); + + test('should return undefined when root node is removed by transformNode returning undefined', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const pageList = [indexPage, aboutPage, historyPage]; + + const navTree = getNavTree(indexPage, pageList, { + // @ts-ignore + transformNode: () => {}, + }); + + expect(navTree).toBeUndefined(); + }); + + test('should preserve order of remaining siblings after removal', () => { + const indexPage = createMockPage('/', 'Home'); + const aPage = createMockPage('/a/', 'A'); + const bPage = createMockPage('/b/', 'B'); + const cPage = createMockPage('/c/', 'C'); + const dPage = createMockPage('/d/', 'D'); + const pageList = [indexPage, aPage, bPage, cPage, dPage]; + + const navTree = getNavTree(indexPage, pageList, { + transformNode: (node) => { + // Remove B and D + if (node.url === '/b/' || node.url === '/d/') { + return null; + } + return node; + }, + }); + + expect(navTree).not.toBeNull(); + expect(navTree?.children).toHaveLength(2); + // Order should be preserved: A, C (B and D removed) + expect(navTree?.children[0]?.url).toBe('/a/'); + expect(navTree?.children[1]?.url).toBe('/c/'); + }); + + test('should produce exact tree structure when parent with descendants is removed (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const contactPage = createMockPage('/contact/', 'Contact'); + const pageList = [indexPage, aboutPage, historyPage, history2025Page, contactPage]; + + const navTree = getNavTree(indexPage, pageList, { + baseDepth: 0, + transformNode: (node) => { + if (node.url === '/about/') { + return null; + } + return node; + }, + }); + + // Verify exact tree structure after removal + expect(navTree).toStrictEqual({ + url: '/', + stem: '/', + depth: 0, + current: true, + isAncestor: false, + title: 'Home', + children: [ + { + url: '/contact/', + stem: '/contact/', + depth: 1, + current: false, + isAncestor: false, + title: 'Contact', + children: [], + }, + ], + }); + }); + + test('should produce exact tree structure when child is removed but siblings remain (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const contactPage = createMockPage('/contact/', 'Contact'); + const servicesPage = createMockPage('/services/', 'Services'); + const pageList = [indexPage, aboutPage, contactPage, servicesPage]; + + const navTree = getNavTree(indexPage, pageList, { + baseDepth: 0, + transformNode: (node) => { + if (node.url === '/contact/') { + return null; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + url: '/', + stem: '/', + depth: 0, + current: true, + isAncestor: false, + title: 'Home', + children: [ + { + url: '/about/', + stem: '/about/', + depth: 1, + current: false, + isAncestor: false, + title: 'About', + children: [], + }, + { + url: '/services/', + stem: '/services/', + depth: 1, + current: false, + isAncestor: false, + title: 'Services', + children: [], + }, + ], + }); + }); + + test('should produce exact tree structure when grandchild is removed (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const history2024Page = createMockPage('/about/history/2024/', '2024'); + const pageList = [ + indexPage, + aboutPage, + historyPage, + history2025Page, + history2024Page, + ]; + + const navTree = getNavTree(history2025Page, pageList, { + baseDepth: 2, + transformNode: (node) => { + if (node.url === '/about/history/2024/') { + return null; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + url: '/about/history/', + stem: '/about/history/', + depth: 2, + current: false, + isAncestor: true, + title: 'History', + children: [ + { + url: '/about/history/2025/', + stem: '/about/history/2025/', + depth: 3, + current: true, + isAncestor: false, + title: '2025', + children: [], + }, + ], + }); + }); + + test('should produce exact tree structure when multiple children are removed via null and undefined (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aPage = createMockPage('/a/', 'A'); + const bPage = createMockPage('/b/', 'B'); + const cPage = createMockPage('/c/', 'C'); + const dPage = createMockPage('/d/', 'D'); + const ePage = createMockPage('/e/', 'E'); + const pageList = [indexPage, aPage, bPage, cPage, dPage, ePage]; + + const navTree = getNavTree(indexPage, pageList, { + baseDepth: 0, + transformNode: (node) => { + if (node.url === '/b/') return null; + if (node.url === '/d/') return; + return node; + }, + }); + + expect(navTree).toStrictEqual({ + url: '/', + stem: '/', + depth: 0, + current: true, + isAncestor: false, + title: 'Home', + children: [ + { + url: '/a/', + stem: '/a/', + depth: 1, + current: false, + isAncestor: false, + title: 'A', + children: [], + }, + { + url: '/c/', + stem: '/c/', + depth: 1, + current: false, + isAncestor: false, + title: 'C', + children: [], + }, + { + url: '/e/', + stem: '/e/', + depth: 1, + current: false, + isAncestor: false, + title: 'E', + children: [], + }, + ], + }); + }); + + test('should produce exact tree structure with deep hierarchy after removal (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const history2025Page = createMockPage('/about/history/2025/', '2025'); + const janPage = createMockPage('/about/history/2025/jan/', 'January'); + const febPage = createMockPage('/about/history/2025/feb/', 'February'); + const pageList = [ + indexPage, + aboutPage, + historyPage, + history2025Page, + janPage, + febPage, + ]; + + const navTree = getNavTree(janPage, pageList, { + baseDepth: 2, + transformNode: (node) => { + if (node.url === '/about/history/2025/feb/') { + return null; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + url: '/about/history/', + stem: '/about/history/', + depth: 2, + current: false, + isAncestor: true, + title: 'History', + children: [ + { + url: '/about/history/2025/', + stem: '/about/history/2025/', + depth: 3, + current: false, + isAncestor: true, + title: '2025', + children: [ + { + url: '/about/history/2025/jan/', + stem: '/about/history/2025/jan/', + depth: 4, + current: true, + isAncestor: false, + title: 'January', + children: [], + }, + ], + }, + ], + }); + }); + + test('should produce empty children array when all descendants are removed (toStrictEqual)', () => { + const indexPage = createMockPage('/', 'Home'); + const aboutPage = createMockPage('/about/', 'About'); + const historyPage = createMockPage('/about/history/', 'History'); + const teamPage = createMockPage('/about/team/', 'Team'); + const pageList = [indexPage, aboutPage, historyPage, teamPage]; + + const navTree = getNavTree(indexPage, pageList, { + baseDepth: 0, + transformNode: (node) => { + if (node.url === '/about/history/' || node.url === '/about/team/') { + return null; + } + return node; + }, + }); + + expect(navTree).toStrictEqual({ + url: '/', + stem: '/', + depth: 0, + current: true, + isAncestor: false, + title: 'Home', + children: [ + { + url: '/about/', + stem: '/about/', + depth: 1, + current: false, + isAncestor: false, + title: 'About', + children: [], + }, + ], + }); + }); + }); +}); diff --git a/packages/@kamado-io/page-compiler/src/features/nav.ts b/packages/@kamado-io/page-compiler/src/features/nav.ts new file mode 100644 index 0000000..bc420ad --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/nav.ts @@ -0,0 +1,233 @@ +import type { Node } from '@d-zero/shared/path-list-to-tree'; +import type { CompilableFile } from 'kamado/files'; + +import path from 'node:path'; + +import { pathListToTree } from '@d-zero/shared/path-list-to-tree'; + +import { getTitleFromStaticFile } from './title.js'; + +/** + * Navigation node with title + */ +export type NavNode = Node & { + /** + * Title + */ + readonly title: string; +}; + +/** + * Options for getting navigation tree + * @template TOut - Type of additional properties added by transformNode + */ +export type GetNavTreeOptions< + TOut extends Record = Record, +> = { + /** + * List of glob patterns for files to ignore + */ + readonly ignoreGlobs?: string[]; + /** + * Function to optimize titles + */ + readonly optimizeTitle?: (title: string) => string; + /** + * Base depth for navigation tree (default: 1) + * - 0: Level 2 (e.g. /about/) + * - 1: Level 3 (e.g. /about/history/) + */ + readonly baseDepth?: number; + /** + * Transform each navigation node + * @param node - Original navigation node + * @returns Transformed navigation node with additional properties (or null/undefined to remove the node) + */ + readonly transformNode?: (node: NavNode) => (NavNode & TOut) | null | undefined; +}; + +/** + * Gets navigation tree corresponding to the current page + * @template TOut - Type of additional properties added by transformNode + * @param currentPage - Current page file + * @param pages - List of all page files (with titles) + * @param options - Options for getting navigation tree + * @returns Navigation tree node at the specified baseDepth (default: current page's depth - 1) or null if not found + * @example + * ```typescript + * const navTree = getNavTree(currentPage, pageList, { + * ignoreGlobs: ['./drafts'], + * }); + * ``` + * @example + * ```typescript + * // With transformNode for adding custom properties + * const navTree = getNavTree(currentPage, pageList, { + * transformNode: (node) => { + * return { + * ...node, + * badge: 'new', + * }; + * }, + * }); + * ``` + */ +export function getNavTree = Record>( + currentPage: CompilableFile, + pages: readonly (CompilableFile & { title: string })[], + options?: GetNavTreeOptions, +): (NavNode & TOut) | null | undefined { + const tree = pathListToTree( + pages.map((item) => item.url), + { + ignoreGlobs: options?.ignoreGlobs, + currentPath: currentPage.url, + filter: (node) => { + const page = pages.find((item) => item.url === node.url); + if (page) { + // @ts-ignore + node.title = page.title; + } else { + const filePath = node.url + (node.url.endsWith('/') ? 'index.html' : ''); + // @ts-ignore + node.title = + getTitleFromStaticFile( + path.join(process.cwd(), 'htdocs', filePath), + options?.optimizeTitle, + ) ?? `⛔️ NOT FOUND (${node.stem})`; + } + return true; + }, + }, + ) as NavNode; + + // Ensure root node has title (pathListToTree might skip filter for root) + if (!tree.title && tree.url) { + const page = pages.find((item) => item.url === tree.url); + if (page) { + // @ts-ignore + tree.title = page.title; + } + } + + const parentTree = getParentNodeTree(currentPage.url, tree, options?.baseDepth); + + if (!parentTree) { + return null; + } + + // Apply transformNode if specified + if (options?.transformNode) { + return transformTreeNodes(parentTree, options.transformNode); + } + + return parentTree as NavNode & TOut; +} + +/** + * Recursively transforms all nodes in a tree + * @param node - Root node to transform + * @param transformNode - Transform function + * @returns Transformed tree + */ +function transformTreeNodes>( + node: NavNode, + transformNode: (node: NavNode) => (NavNode & TOut) | null | undefined, +): (NavNode & TOut) | null | undefined { + const transformedChildren = node.children + .map((child) => transformTreeNodes(child as NavNode, transformNode)) + .filter((child): child is NavNode & TOut => !!child); + + const transformedNode = transformNode({ + ...node, + children: transformedChildren, + }); + + return transformedNode; +} + +/** + * Finds the node corresponding to the current page in the tree + * @param tree - Navigation tree + * @returns Found node or null if not found + */ +function findCurrentNode(tree: Node): Node | null { + if (tree.current) { + return tree; + } + for (const child of tree.children) { + const found = findCurrentNode(child); + if (found) { + return found; + } + } + return null; +} + +/** + * Finds ancestor node at the specified depth + * @param currentUrl - Current page URL + * @param tree - Navigation tree + * @param targetDepth - Target depth to find ancestor + * @returns Found ancestor node or null if not found + */ +function findAncestorAtDepth( + currentUrl: string, + tree: NavNode, + targetDepth: number, +): NavNode | null { + targetDepth = Math.max(0, targetDepth); + if (tree.depth === targetDepth) { + return tree; + } + const dirName = path.dirname(currentUrl) + '/'; + const candidateParent = tree.children.find((child) => + dirName.startsWith(child.url.endsWith('/') ? child.url : path.dirname(child.url)), + ); + if (!candidateParent) { + return null; + } + + const found = findAncestorAtDepth(currentUrl, candidateParent as NavNode, targetDepth); + if (found) { + return found; + } + + return null; +} + +/** + * Returns the navigation tree corresponding to the current page at the specified base depth + * + * Note: The relationship between specification "level N" and path-list-to-tree "depth" is as follows: + * - Specification level 1 (/) = depth 0 + * - Specification level 2 (/about/) = depth 1 + * - Specification level 3 (/about/history/) = depth 2 + * - Specification level 4 (/about/history/2025/) = depth 3 + * In other words, specification "level N" = depth (N-1) + * @param currentUrl - Current page URL + * @param tree - Navigation tree + * @param baseDepth - Base depth for navigation tree (default: current page's depth - 1) + * @returns Ancestor node at baseDepth or null if not found + */ +function getParentNodeTree( + currentUrl: string, + tree: NavNode, + baseDepth?: number, +): NavNode | null { + // Find the node for the current page + const currentNode = findCurrentNode(tree); + + if (!currentNode) { + return null; + } + + // Find the ancestor node at baseDepth (default: current page's depth - 1) + const ancestor = findAncestorAtDepth( + currentUrl, + tree, + baseDepth ?? currentNode.depth - 1, + ); + + return ancestor; +} diff --git a/packages/@kamado-io/page-compiler/src/features/title-list.ts b/packages/@kamado-io/page-compiler/src/features/title-list.ts new file mode 100644 index 0000000..3fff8b1 --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/title-list.ts @@ -0,0 +1,78 @@ +import type { BreadcrumbItem } from './breadcrumbs.js'; + +/** + * Options for generating title list + */ +export type TitleListOptions = { + /** + * Separator between titles + * @default ' | ' + */ + readonly separator?: string; + /** + * Base URL (items with this URL are excluded) + * @default '/' + */ + readonly baseURL?: string; + /** + * String to prepend to title + * @default '' + */ + readonly prefix?: string; + /** + * String to append to title + * @default options.siteName + */ + readonly suffix?: string; + /** + * Site name + */ + readonly siteName?: string; + /** + * Fallback string when title is empty + * @default options.siteName + */ + readonly fallback?: string; +}; + +/** + * Generates title string from breadcrumb list + * @param breadcrumbs - Array of breadcrumb items + * @param options - Options for generating title list + * @returns Generated title string + * @example + * ```typescript + * const title = titleList(breadcrumbs, { + * separator: ' | ', + * siteName: 'My Site', + * prefix: '📄 ', + * }); + * // Returns: "📄 Page Title | Section | My Site" + * ``` + */ +export function titleList(breadcrumbs: BreadcrumbItem[], options: TitleListOptions = {}) { + const { + separator = ' | ', + baseURL = '/', + prefix = '', + suffix = options.siteName, + fallback = options.siteName, + } = options; + + const titleList = breadcrumbs + .filter((item) => item.href !== baseURL && item.href !== '/') + .toReversed() + .map((item) => item.title?.trim()) + .filter((item): item is string => item != null); + if (titleList.length === 0 && fallback) { + titleList.push(fallback.trim()); + } + let title = titleList.join(separator); + if (prefix) { + title = prefix.trim() + title; + } + if (suffix) { + title = title + suffix.trim(); + } + return title; +} diff --git a/packages/@kamado-io/page-compiler/src/features/title.ts b/packages/@kamado-io/page-compiler/src/features/title.ts new file mode 100644 index 0000000..f7619a5 --- /dev/null +++ b/packages/@kamado-io/page-compiler/src/features/title.ts @@ -0,0 +1,73 @@ +import type { CompilableFile } from 'kamado/files'; + +import fs from 'node:fs'; + +const titleCache = new Map(); + +/** + * Gets page title + * @param page - Page file + * @param optimizeTitle - Function to optimize title (optional) + * @param safe - Whether to return an empty string if the page content is not found + * @returns Page title (from metadata.title, HTML tag, or file slug as fallback) or empty string if safe is true + */ +export async function getTitle( + page: CompilableFile, + optimizeTitle?: (title: string) => string, + safe?: boolean, +) { + const filePathStem = page.filePathStem; + if (titleCache.has(filePathStem)) { + return titleCache.get(filePathStem); + } + const pageContent = await page.get().catch((error) => { + if (safe) { + return null; + } + throw error; + }); + if (!pageContent) { + return ''; + } + const { metaData, content } = pageContent; + const title = + (metaData.title as string | undefined) || + getTitleFromDOM(content, optimizeTitle) || + page.fileSlug; + titleCache.set(filePathStem, title); + return title; +} + +/** + * Gets title from static HTML file + * @param filePath - HTML file path + * @param optimizeTitle - Function to optimize title (optional) + * @returns Title (null if not found) + */ +export function getTitleFromStaticFile( + filePath: string, + optimizeTitle?: (title: string) => string, +) { + if (titleCache.has(filePath)) { + return titleCache.get(filePath); + } + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, 'utf8'); + let title = getTitleFromDOM(content, optimizeTitle); + title = optimizeTitle?.(title) ?? title; + titleCache.set(filePath, title); + return title; +} + +/** + * Extracts title from HTML content using DOM parsing + * @param content - HTML content + * @param optimizeTitle - Function to optimize title (optional) + * @returns Extracted title string (empty string if not found) + */ +function getTitleFromDOM(content: string, optimizeTitle?: (title: string) => string) { + const title = /<title>(.*?)<\/title>/i.exec(content)?.[1]?.trim() || ''; + return optimizeTitle?.(title) ?? title; +} diff --git a/packages/@kamado-io/page-compiler/src/index.spec.ts b/packages/@kamado-io/page-compiler/src/index.spec.ts index 1c0d238..806dcf9 100644 --- a/packages/@kamado-io/page-compiler/src/index.spec.ts +++ b/packages/@kamado-io/page-compiler/src/index.spec.ts @@ -2,7 +2,10 @@ import type { PageCompilerOptions } from './index.js'; import type { CompilableFile } from 'kamado/files'; import { mergeConfig } from 'kamado/config'; -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, expectTypeOf } from 'vitest'; + +import { type BreadcrumbItem } from './features/breadcrumbs.js'; +import { type NavNode } from './features/nav.js'; import { pageCompiler } from './index.js'; @@ -180,3 +183,57 @@ describe('page compiler', async () => { expect(result).toBe('<p>Hello, world!</p>\n'); }); }); + +describe('type inference for transform options', () => { + describe('PageCompilerOptions transform functions', () => { + test('transformBreadcrumbItem should accept valid function', () => { + const options: PageCompilerOptions = { + transformBreadcrumbItem: (item) => ({ + ...item, + icon: 'test', + }), + }; + + expectTypeOf(options.transformBreadcrumbItem).toExtend< + ((item: BreadcrumbItem) => BreadcrumbItem) | undefined + >(); + }); + + test('transformNavNode should accept valid function', () => { + const options: PageCompilerOptions = { + transformNavNode: (node) => ({ + ...node, + badge: 'test', + }), + }; + + expectTypeOf(options.transformNavNode).toExtend< + ((node: NavNode) => NavNode | null | undefined) | undefined + >(); + }); + + test('transformBreadcrumbItem should accept sync function', () => { + const options: PageCompilerOptions = { + transformBreadcrumbItem: (item) => { + return { + ...item, + }; + }, + }; + + expect(options.transformBreadcrumbItem).toBeDefined(); + }); + + test('transformNavNode should accept sync function', () => { + const options: PageCompilerOptions = { + transformNavNode: (node) => { + return { + ...node, + }; + }, + }; + + expect(options.transformNavNode).toBeDefined(); + }); + }); +}); diff --git a/packages/@kamado-io/page-compiler/src/index.ts b/packages/@kamado-io/page-compiler/src/index.ts index cc1cb82..b0db471 100644 --- a/packages/@kamado-io/page-compiler/src/index.ts +++ b/packages/@kamado-io/page-compiler/src/index.ts @@ -1,6 +1,8 @@ +import type { BreadcrumbItem } from './features/breadcrumbs.js'; +import type { GetNavTreeOptions, NavNode } from './features/nav.js'; +import type { TitleListOptions } from './features/title-list.js'; import type { Options as HMTOptions } from 'html-minifier-terser'; import type { Config } from 'kamado/config'; -import type { GetNavTreeOptions, TitleListOptions } from 'kamado/features'; import type { CompilableFile, FileObject } from 'kamado/files'; import type { Options as PrettierOptions } from 'prettier'; @@ -12,7 +14,6 @@ import fg from 'fast-glob'; import { minify } from 'html-minifier-terser'; import { createCompiler } from 'kamado/compiler'; import { getGlobalData } from 'kamado/data'; -import { getBreadcrumbs, getNavTree, titleList } from 'kamado/features'; import { getFileContent } from 'kamado/files'; import { domSerialize } from 'kamado/utils/dom'; import { @@ -20,6 +21,9 @@ import { resolveConfig as prettierResolveConfig, } from 'prettier'; +import { getBreadcrumbs } from './features/breadcrumbs.js'; +import { getNavTree } from './features/nav.js'; +import { titleList } from './features/title-list.js'; import { imageSizes, type ImageSizesOptions } from './image.js'; /** @@ -135,6 +139,35 @@ export interface PageCompilerOptions { * Can be an object or a function that returns an object */ readonly compileHooks?: CompileHooks; + /** + * Transform each breadcrumb item + * @param item - Original breadcrumb item + * @returns Transformed breadcrumb item (can include additional properties) + * @example + * ```typescript + * pageCompiler({ + * transformBreadcrumbItem: (item) => ({ + * ...item, + * icon: item.href === '/' ? 'home' : 'page', + * }), + * }); + * ``` + */ + readonly transformBreadcrumbItem?: (item: BreadcrumbItem) => BreadcrumbItem; + /** + * Transform each navigation node + * @param node - Original navigation node + * @returns Transformed navigation node (can include additional properties, or null/undefined to remove the node) + * @example + * ```typescript + * pageCompiler({ + * transformNavNode: (node) => { + * return { ...node, badge: 'new' }; + * }, + * }); + * ``` + */ + readonly transformNavNode?: (node: NavNode) => NavNode | null | undefined; } /** @@ -148,7 +181,7 @@ export interface CompileData extends Record<string, unknown> { /** * Navigation tree function */ - readonly nav: (options: GetNavTreeOptions) => unknown; + readonly nav: (options: GetNavTreeOptions) => NavNode | null | undefined; /** * Title list function */ @@ -290,9 +323,10 @@ export const pageCompiler = createCompiler<PageCompilerOptions>(() => ({ const pageContent = await file.get(cache); const { metaData, content: pageMainContent } = pageContent; - const breadcrumbs = await getBreadcrumbs(file, globalData?.pageList ?? [], { + const breadcrumbs = getBreadcrumbs(file, globalData?.pageList ?? [], { baseURL: config.pkg.production?.baseURL, optimizeTitle: options?.optimizeTitle, + transformItem: options?.transformBreadcrumbItem, }); const compileData: CompileData = { @@ -300,12 +334,11 @@ export const pageCompiler = createCompiler<PageCompilerOptions>(() => ({ ...metaData, page: file, nav: (navOptions: GetNavTreeOptions) => - getNavTree( - file, - globalData?.pageList ?? [], - options?.optimizeTitle, - navOptions, - ), + getNavTree(file, globalData?.pageList ?? [], { + optimizeTitle: options?.optimizeTitle, + ...navOptions, + transformNode: options?.transformNavNode, + }), titleList: (options: TitleListOptions) => titleList(breadcrumbs, { siteName: config.pkg.production?.siteName, diff --git a/packages/kamado/src/features/breadcrumbs.ts b/packages/kamado/src/features/breadcrumbs.ts index 0f7ce82..59077ae 100644 --- a/packages/kamado/src/features/breadcrumbs.ts +++ b/packages/kamado/src/features/breadcrumbs.ts @@ -39,6 +39,8 @@ export type GetBreadcrumbsOptions = { /** * Gets breadcrumb list for a page + * @deprecated This function will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. * @param page - Target page file * @param pageList - List of all page files * @param options - Options for getting breadcrumbs diff --git a/packages/kamado/src/features/index.ts b/packages/kamado/src/features/index.ts index 87aabd1..23e0f9f 100644 --- a/packages/kamado/src/features/index.ts +++ b/packages/kamado/src/features/index.ts @@ -1,3 +1,9 @@ +/** + * @deprecated This module will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. + * @module + */ + export { getBreadcrumbs } from './breadcrumbs.js'; export { getNavTree, type GetNavTreeOptions } from './nav.js'; export { titleList, type TitleListOptions } from './title-list.js'; diff --git a/packages/kamado/src/features/nav.ts b/packages/kamado/src/features/nav.ts index fe54bd1..cc612dc 100644 --- a/packages/kamado/src/features/nav.ts +++ b/packages/kamado/src/features/nav.ts @@ -19,6 +19,8 @@ export type GetNavTreeOptions = { /** * Gets navigation tree corresponding to the current page + * @deprecated This function will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. * @param currentPage - Current page file * @param pages - List of all page files (with titles) * @param optimizeTitle - Function to optimize titles (optional) diff --git a/packages/kamado/src/features/title-list.ts b/packages/kamado/src/features/title-list.ts index 3fff8b1..b0d17b4 100644 --- a/packages/kamado/src/features/title-list.ts +++ b/packages/kamado/src/features/title-list.ts @@ -37,6 +37,8 @@ export type TitleListOptions = { /** * Generates title string from breadcrumb list + * @deprecated This function will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. * @param breadcrumbs - Array of breadcrumb items * @param options - Options for generating title list * @returns Generated title string diff --git a/packages/kamado/src/features/title.ts b/packages/kamado/src/features/title.ts index 7cfc13d..8af4feb 100644 --- a/packages/kamado/src/features/title.ts +++ b/packages/kamado/src/features/title.ts @@ -6,6 +6,8 @@ const titleCache = new Map<string, string>(); /** * Gets page title + * @deprecated This function will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. * @param page - Page file * @param optimizeTitle - Function to optimize title (optional) * @param safe - Whether to return an empty string if the page content is not found @@ -40,6 +42,8 @@ export async function getTitle( /** * Gets title from static HTML file + * @deprecated This function will be removed in the next major version (v2.0.0). + * Import from '@kamado-io/page-compiler' instead. * @param filePath - HTML file path * @param optimizeTitle - Function to optimize title (optional) * @returns Title (null if not found)