Skip to content

Commit

Permalink
feat: generate nav
Browse files Browse the repository at this point in the history
  • Loading branch information
yjl9903 committed Feb 26, 2023
1 parent e285c20 commit 54f6916
Show file tree
Hide file tree
Showing 6 changed files with 420 additions and 11 deletions.
14 changes: 11 additions & 3 deletions packages/core/src/epub/epub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as path from 'pathe';
import { existsSync, mkdirSync, promises as fs } from 'node:fs';

import { Item } from './item';
import { PackageDocument, PackageDocumentMeta } from './opf';
import { NavOption, PackageDocument, PackageDocumentMeta } from './opf';

export class Epub {
/**
Expand All @@ -26,8 +26,16 @@ export class Epub {
return this.opfs[0];
}

public addItem(item: Item) {
this.opfs[0].addItem(item);
public addItem(...items: Item[]) {
for (const item of items) {
this.opfs[0].addItem(item);
}
return this;
}

public toc(nav: NavOption, title?: string) {
this.opfs[0].toc(nav, title);
return this;
}

async bundle() {
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/epub/nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { XHTMLBuilder, XHTMLNode } from './xhtml';

interface NavRoot {
heading?: 1 | 2 | 3 | 4 | 5 | 6;

title?: string;

list: NavItem[];
}

interface SubNavItem {
href?: string;

text: string;
}

interface NavItem extends SubNavItem {
list?: SubNavItem[];
}

export function buildTocNav(nav: NavRoot) {
const builder = new XHTMLBuilder();

const root = {
tag: 'nav',
attrs: {
'epub:type': 'toc'
},
children: [] as XHTMLNode[]
} satisfies XHTMLNode;

if (nav.title && nav.heading) {
root.children.push({
tag: 'h' + nav.heading,
attrs: {},
children: nav.title
});
}
root.children.push({
tag: 'ol',
attrs: {},
children: list(nav.list)
});

return builder
.title(nav.title ?? 'Toc')
.body(root)
.build();

function list(items: Array<SubNavItem | NavItem>): XHTMLNode[] {
return items.map((i) => ({
tag: 'li',
attrs: {},
children: [
i.href
? { tag: 'a', attrs: { href: i.href }, children: i.text }
: { tag: 'span', attrs: {}, children: i.text },
'list' in i &&
i.list && {
tag: 'ol',
attrs: {},
children: list(i.list)
}
].filter(Boolean) as XHTMLNode[]
}));
}
}
33 changes: 27 additions & 6 deletions packages/core/src/epub/opf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';

import { createDefu } from 'defu';

import { Item, ManifestItem, ManifestItemRef } from './item';
import { Html, Item, ManifestItem, ManifestItemRef } from './item';
import { buildTocNav } from './nav';

const defu = createDefu((obj: any, key, value: any) => {
if (obj[key] instanceof Date && value instanceof Date) {
Expand Down Expand Up @@ -56,9 +57,9 @@ export class PackageDocument {
lastModified: new Date()
};

private _items: Item[] = [];
private _toc: Html | undefined;

private _manifest: ManifestItem[] = [];
private _items: Item[] = [];

private _spine: ManifestItemRef[] = [];

Expand Down Expand Up @@ -100,21 +101,39 @@ export class PackageDocument {
// --- manifest ---
public addItem(item: Item) {
this._items.push(item);
this._manifest.push(item.manifest());
}

public items() {
return this._items;
const l = [...this._items];
if (this._toc) {
l.push(this._toc);
}
return l;
}

public manifest() {
return this._manifest;
return this.items().map((i) => i.manifest());
}

public spine() {
return this._spine;
}

// --- navigation ---
public toc(nav: NavOption, title?: string) {
const content = buildTocNav({
heading: 2,
title,
list: nav.map((i) =>
Array.isArray(i.item)
? { text: i.text, list: i.item.map((i) => ({ href: i.item.filename(), text: i.text })) }
: { href: i.item.filename(), text: i.text }
)
});
this._toc = new Html('nav.xhtml', content).update({ properties: 'nav' });
return this;
}

// --- identifier ---
public uniqueIdentifier() {
return this._uniqueIdentifier;
Expand All @@ -129,3 +148,5 @@ export class PackageDocument {
this._uniqueIdentifier = uniqueIdentifier;
}
}

export type NavOption = Array<{ text: string; item: Html | Array<{ text: string; item: Html }> }>;
101 changes: 101 additions & 0 deletions packages/core/src/epub/xhtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { XMLBuilder } from 'fast-xml-parser';

import { TextCSS } from '../constant';

const builder = new XMLBuilder({
format: true,
ignoreAttributes: false,
suppressUnpairedNode: false,
unpairedTags: ['link']
});

export interface XHTMLNode {
tag: string;
attrs: Record<string, string>;
children?: string | Array<XHTMLNode>;
}

export class XHTMLBuilder {
private info = {
language: 'en',
title: ''
};

private _head: XHTMLNode[] = [];

private _body: XHTMLNode[] = [];

constructor() {}

language(value: string) {
this.info.language = value;
return this;
}

title(value: string) {
this.info.title = value;
return this;
}

style(href: string) {
this._head.push({
tag: 'link',
attrs: {
href,
rel: 'stylesheet',
type: TextCSS
},
children: ''
});
return this;
}

body(node: XHTMLNode) {
this._body.push(node);
return this;
}

public build(): string {
function build(node: XHTMLNode) {
const attrs = Object.fromEntries(
Object.entries(node.attrs).map(([key, value]) => ['@_' + key, value])
);

const obj: any = {
...attrs
};
if (typeof node.children === 'string') {
obj['#text'] = node.children;
} else if (Array.isArray(node.children)) {
Object.assign(obj, list(node.children));
}

return obj;
}

function list(nodes: XHTMLNode[]) {
const obj: any = {};
for (const c of nodes) {
if (c.tag in obj) {
obj[c.tag].push(build(c));
} else {
obj[c.tag] = [build(c)];
}
}
return obj;
}

return builder.build({
html: {
'@_xmlns': 'http://www.w3.org/1999/xhtml',
'@_xmlns:epub': 'http://www.w3.org/1999/xhtml',
'@_xml:lang': this.info.language,
head: {
title: this.info.title,
...list(this._head)
},
body: list(this._body)
}
});
}
}
98 changes: 98 additions & 0 deletions packages/core/test/__snapshots__/bundle.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Bundle Epub > generate toc 1`] = `
PackageDocument {
"SpecVersion": "3.0",
"_identifier": "12345",
"_items": [
Html {
"_properties": undefined,
"content": "1",
"file": "page1.xhtml",
"mediaType": "application/xhtml+xml",
},
Html {
"_properties": undefined,
"content": "2",
"file": "page2.xhtml",
"mediaType": "application/xhtml+xml",
},
Html {
"_properties": undefined,
"content": "3",
"file": "page3.xhtml",
"mediaType": "application/xhtml+xml",
},
Html {
"_properties": undefined,
"content": "4",
"file": "page4.xhtml",
"mediaType": "application/xhtml+xml",
},
Html {
"_properties": undefined,
"content": "5",
"file": "page5.xhtml",
"mediaType": "application/xhtml+xml",
},
],
"_metadata": {
"contributor": [],
"coverage": "",
"creator": "XLor",
"date": 2023-02-01T11:00:00.000Z,
"description": "for test usage",
"format": "",
"language": "zh-CN",
"lastModified": 2023-02-26T11:00:00.000Z,
"publisher": "",
"relation": "",
"rights": "",
"source": "imagine",
"subject": "",
"title": "Test Book",
"type": "",
},
"_spine": [],
"_toc": Html {
"_properties": "nav",
"content": "<html xmlns=\\"http://www.w3.org/1999/xhtml\\" xmlns:epub=\\"http://www.w3.org/1999/xhtml\\" xml:lang=\\"en\\">
<head>
<title>Toc</title>
</head>
<body>
<nav epub:type=\\"toc\\">
<h2>Toc</h2>
<ol>
<li>
<a href=\\"page1.xhtml\\">1</a>
</li>
<li>
<a href=\\"page2.xhtml\\">2</a>
</li>
<li>
<span>Sub</span>
<ol>
<li>
<a href=\\"page3.xhtml\\">3</a>
</li>
<li>
<a href=\\"page4.xhtml\\">4</a>
</li>
</ol>
</li>
<li>
<a href=\\"page5.xhtml\\">5</a>
</li>
</ol>
</nav>
</body>
</html>
",
"file": "nav.xhtml",
"mediaType": "application/xhtml+xml",
},
"_uniqueIdentifier": "book-id",
"file": "OEBPS/content.opf",
}
`;
Loading

0 comments on commit 54f6916

Please sign in to comment.