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: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions MILESTONE.md
Original file line number Diff line number Diff line change
@@ -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) を参照してください。
2 changes: 2 additions & 0 deletions packages/@kamado-io/page-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> | string`
- `afterSerialize`: Hook function called after DOM serialization `(elements: readonly Element[], window: Window, isServe: boolean) => Promise<void> | void`
Expand Down
203 changes: 203 additions & 0 deletions packages/@kamado-io/page-compiler/src/features/breadcrumbs.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {},
): 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: '<p>content</p>',
raw: '<p>content</p>',
}),
};
}

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<string, string> = {
'/': '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/']);
});
});
});
121 changes: 121 additions & 0 deletions packages/@kamado-io/page-compiler/src/features/breadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = Record<never, never>,
> = {
/**
* 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<string, unknown> = Record<never, never>,
>(
page: CompilableFile & { title?: string },
pageList: readonly (CompilableFile & { title?: string })[],
options?: GetBreadcrumbsOptions<TOut>,
): (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;
}
Loading