Skip to content

Commit 3e76218

Browse files
feat(pretext): implement pretext, leveraging previously implemented configuration parameters
1 parent cfca5eb commit 3e76218

4 files changed

Lines changed: 274 additions & 72 deletions

File tree

build/paginate-pieces.ts

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,17 @@ import fs from 'fs';
22
import path from 'path';
33
import fm from "front-matter";
44
import yaml from 'js-yaml';
5-
import { chunkContent, getCharsPerPage, PaginationConfig } from './utils/markdown-chunker';
5+
import { chunkContent, getCharsPerPage, getLinesPerPage, buildMeasureFn, MeasureFn, PaginationConfig } from './utils/markdown-chunker';
66
import { loadTheme } from './utils/theme-loader';
7+
import { installCanvasPolyfill } from './utils/canvas-polyfill';
8+
import { resolveFontSpec } from './utils/font-loader';
79

810
const publicDir = path.join(__dirname, '..', 'public');
911
const piecesPath = path.join(publicDir, 'content', 'pieces');
1012
const pagesIndexPath = path.join(publicDir, 'generated', 'index', 'pieces-pages.json');
1113
const piecesIndexPath = path.join(publicDir, 'generated', 'index', 'pieces.json');
1214
const configPath = path.join(publicDir, 'config.yaml');
1315

14-
const configRaw = fs.readFileSync(configPath, 'utf-8');
15-
const config = yaml.load(configRaw) as any;
16-
17-
const themeName = config?.ui?.theme?.preset || config?.theme || 'journal';
18-
const theme = loadTheme(themeName);
19-
const themeScaleOverride = config?.ui?.theme?.overrides?.font?.scale;
20-
const themeScale = themeScaleOverride ?? theme?.font?.scale ?? 1;
21-
22-
const paginationConfig: PaginationConfig = {
23-
columns: config?.reader?.columns ?? 2,
24-
pagination: config?.reader?.pagination,
25-
};
26-
27-
const CHARS_PER_PAGE = getCharsPerPage(paginationConfig, themeScale);
28-
29-
console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, charsPerPage=${CHARS_PER_PAGE}`);
30-
3116
type PiecePage = {
3217
pieceSlug: string;
3318
pageNumber: number;
@@ -42,42 +27,76 @@ type PiecePageIndex = {
4227
};
4328
};
4429

45-
const piecesIndexRaw = fs.readFileSync(piecesIndexPath, 'utf-8');
46-
const piecesIndex = JSON.parse(piecesIndexRaw);
30+
async function main() {
31+
const configRaw = fs.readFileSync(configPath, 'utf-8');
32+
const config = yaml.load(configRaw) as any;
4733

48-
const pageIndex: PiecePageIndex = {};
34+
const themeName = config?.ui?.theme?.preset || config?.theme || 'journal';
35+
const theme = loadTheme(themeName);
36+
const themeScaleOverride = config?.ui?.theme?.overrides?.font?.scale;
37+
const themeScale = themeScaleOverride ?? theme?.font?.scale ?? 1;
4938

50-
piecesIndex.forEach((piece: any) => {
51-
const { slug } = piece;
52-
const filePath = path.join(piecesPath, `${slug}.md`);
53-
54-
if (!fs.existsSync(filePath)) {
55-
console.warn(`[pagination]: file not found for slug: ${slug}`);
56-
return;
57-
}
58-
59-
const raw = fs.readFileSync(filePath, 'utf-8');
60-
const parsed = fm(raw);
61-
const content = parsed.body;
62-
63-
const chunks = chunkContent(content, CHARS_PER_PAGE);
64-
const totalPages = chunks.length;
65-
66-
const pages: PiecePage[] = chunks.map((chunk, index) => ({
67-
pieceSlug: slug,
68-
pageNumber: index + 1,
69-
content: chunk,
70-
totalPages
71-
}));
72-
73-
pageIndex[slug] = {
74-
totalPages,
75-
pages
39+
const paginationConfig: PaginationConfig = {
40+
columns: config?.reader?.columns ?? 2,
41+
pagination: config?.reader?.pagination,
7642
};
77-
78-
console.log(`[pagination]: ${slug}: ${totalPages} page(s)`);
79-
});
8043

81-
fs.writeFileSync(pagesIndexPath, JSON.stringify(pageIndex, null, 2));
82-
console.log(`[pagination]: created pieces-pages.json with ${Object.keys(pageIndex).length} pieces`);
44+
const p = paginationConfig.pagination ?? {};
45+
const columnWidth = p.columnWidth ?? 330;
46+
const lineHeight = p.lineHeight ?? 24;
47+
const avgCharWidth = p.avgCharWidth ?? 8;
48+
49+
const CHARS_PER_PAGE = getCharsPerPage(paginationConfig, themeScale);
50+
51+
await installCanvasPolyfill();
52+
53+
let measureFn: MeasureFn | undefined;
54+
let linesPerPage: number | undefined;
55+
56+
if (theme) {
57+
const fontSpec = await resolveFontSpec(theme, themeScale, lineHeight);
58+
measureFn = await buildMeasureFn(fontSpec.cssFontString, columnWidth, lineHeight * themeScale, avgCharWidth);
59+
linesPerPage = getLinesPerPage(paginationConfig, themeScale);
60+
console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, linesPerPage=${linesPerPage}, font=${fontSpec.cssFontString}`);
61+
} else {
62+
console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, charsPerPage=${CHARS_PER_PAGE} (no theme, char-based fallback)`);
63+
}
64+
65+
const piecesIndexRaw = fs.readFileSync(piecesIndexPath, 'utf-8');
66+
const piecesIndex = JSON.parse(piecesIndexRaw);
67+
68+
const pageIndex: PiecePageIndex = {};
69+
70+
for (const piece of piecesIndex) {
71+
const { slug } = piece;
72+
const filePath = path.join(piecesPath, `${slug}.md`);
73+
74+
if (!fs.existsSync(filePath)) {
75+
console.warn(`[pagination]: file not found for slug: ${slug}`);
76+
continue;
77+
}
78+
79+
const raw = fs.readFileSync(filePath, 'utf-8');
80+
const parsed = fm(raw);
81+
const content = parsed.body;
82+
83+
const chunks = chunkContent(content, CHARS_PER_PAGE, measureFn, linesPerPage, columnWidth, avgCharWidth);
84+
const totalPages = chunks.length;
85+
86+
const pages: PiecePage[] = chunks.map((chunk, index) => ({
87+
pieceSlug: slug,
88+
pageNumber: index + 1,
89+
content: chunk,
90+
totalPages
91+
}));
92+
93+
pageIndex[slug] = { totalPages, pages };
94+
95+
console.log(`[pagination]: ${slug}: ${totalPages} page(s)`);
96+
}
97+
98+
fs.writeFileSync(pagesIndexPath, JSON.stringify(pageIndex, null, 2));
99+
console.log(`[pagination]: created pieces-pages.json with ${Object.keys(pageIndex).length} pieces`);
100+
}
83101

102+
main().catch(err => { console.error(err); process.exit(1); });

build/utils/canvas-polyfill.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export async function installCanvasPolyfill(): Promise<void> {
2+
const { createCanvas } = await import('canvas');
3+
4+
if (typeof (globalThis as any).OffscreenCanvas === 'undefined') {
5+
(globalThis as any).OffscreenCanvas = class OffscreenCanvas {
6+
private _canvas: ReturnType<typeof createCanvas>;
7+
8+
constructor(width: number, height: number) {
9+
this._canvas = createCanvas(width, height);
10+
}
11+
12+
getContext(type: '2d') {
13+
return this._canvas.getContext(type);
14+
}
15+
};
16+
}
17+
}

build/utils/font-loader.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import os from 'os';
4+
import { ThemeConfig } from './theme-loader';
5+
6+
export type FontSpec = {
7+
family: string;
8+
cssFontString: string;
9+
registered: boolean;
10+
};
11+
12+
export async function resolveFontSpec(
13+
theme: ThemeConfig,
14+
themeScale: number,
15+
lineHeight: number
16+
): Promise<FontSpec> {
17+
const { registerFont } = await import('canvas');
18+
19+
const rawFamily = theme.font.family.split(',')[0].trim().replace(/['"]/g, '');
20+
const fontSize = Math.round(lineHeight * themeScale * 0.75);
21+
const cssFontString = `${fontSize}px ${theme.font.family}`;
22+
23+
const fontUrl = theme.font.url;
24+
25+
if (!fontUrl.startsWith('http') && fontUrl.match(/\.(ttf|otf)$/i)) {
26+
const absolutePath = path.isAbsolute(fontUrl)
27+
? fontUrl
28+
: path.join(process.cwd(), fontUrl);
29+
30+
if (fs.existsSync(absolutePath)) {
31+
try {
32+
registerFont(absolutePath, { family: rawFamily });
33+
console.log(`[font-loader]: registered local font "${rawFamily}" from ${absolutePath}`);
34+
return { family: rawFamily, cssFontString, registered: true };
35+
} catch (err) {
36+
console.warn(`[font-loader]: failed to register local font: ${err}`);
37+
}
38+
}
39+
}
40+
41+
if (fontUrl.startsWith('http')) {
42+
const ttfPath = await fetchGoogleFontTTF(fontUrl, rawFamily);
43+
if (ttfPath) {
44+
try {
45+
registerFont(ttfPath, { family: rawFamily });
46+
console.log(`[font-loader]: registered remote font "${rawFamily}" (tmp: ${ttfPath})`);
47+
return { family: rawFamily, cssFontString, registered: true };
48+
} catch (err) {
49+
console.warn(`[font-loader]: failed to register remote font: ${err}`);
50+
}
51+
}
52+
}
53+
54+
console.warn(`[font-loader]: could not register font "${rawFamily}", measurements will use system fallback`);
55+
return { family: rawFamily, cssFontString, registered: false };
56+
}
57+
58+
async function fetchGoogleFontTTF(googleFontsUrl: string, family: string): Promise<string | null> {
59+
try {
60+
const cssResponse = await fetch(googleFontsUrl, {
61+
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36' }
62+
});
63+
64+
if (!cssResponse.ok) {
65+
console.warn(`[font-loader]: failed to fetch font CSS (${cssResponse.status})`);
66+
return null;
67+
}
68+
69+
const css = await cssResponse.text();
70+
71+
const ttfMatch =
72+
css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]?truetype['"]?\)/) ||
73+
css.match(/src:\s*url\(([^)]+\.ttf)\)/);
74+
75+
if (!ttfMatch) {
76+
console.warn(`[font-loader]: no TTF URL found in font CSS for "${family}"`);
77+
return null;
78+
}
79+
80+
const fontFileUrl = ttfMatch[1];
81+
const fontResponse = await fetch(fontFileUrl);
82+
83+
if (!fontResponse.ok) {
84+
console.warn(`[font-loader]: failed to download TTF for "${family}"`);
85+
return null;
86+
}
87+
88+
const buffer = Buffer.from(await fontResponse.arrayBuffer());
89+
const tmpPath = path.join(os.tmpdir(), `ode-font-${family.replace(/\s+/g, '-')}.ttf`);
90+
fs.writeFileSync(tmpPath, buffer);
91+
92+
return tmpPath;
93+
} catch (err) {
94+
console.warn(`[font-loader]: error fetching font "${family}": ${err}`);
95+
return null;
96+
}
97+
}

0 commit comments

Comments
 (0)