From a7be152bd66e4d8e340a7427f360245a9b9857a9 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 16:02:51 +0530 Subject: [PATCH 1/9] hotfix: a more liberal chunking algorithm that sends split items to the next page --- build/utils/markdown-chunker.ts | 84 +++++++++++++++------------------ 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/build/utils/markdown-chunker.ts b/build/utils/markdown-chunker.ts index 15dab9f..1db9bed 100644 --- a/build/utils/markdown-chunker.ts +++ b/build/utils/markdown-chunker.ts @@ -120,6 +120,9 @@ export function splitList(block: Block, remainingBudget: number): [string, strin items.push(currentItem); } + const safetyBuffer = 0.8; + const safeBudget = remainingBudget * safetyBuffer; + let usedLength = 0; let splitIndex = 0; @@ -127,17 +130,13 @@ export function splitList(block: Block, remainingBudget: number): [string, strin const itemContent = items[i].join('\n'); const itemEffectiveLength = Math.ceil(itemContent.length * SCALE_FACTORS.list); - if (usedLength + itemEffectiveLength > remainingBudget && i > 0) { + if (usedLength + itemEffectiveLength > safeBudget) { break; } usedLength += itemEffectiveLength; splitIndex = i + 1; } - if (splitIndex === 0) { - splitIndex = 1; - } - const firstPart = items.slice(0, splitIndex).map(item => item.join('\n')).join('\n'); const secondPart = items.slice(splitIndex).map(item => item.join('\n')).join('\n'); @@ -201,50 +200,45 @@ export function chunkContent(content: string, charsPerPage: number): string[] { continue; } - const remainingBudget = charsPerPage - currentEffectiveLength; - - if (block.type === 'list' && block.effectiveLength > charsPerPage * 0.3) { - const [firstPart, secondPart] = splitList(block, remainingBudget); - - if (firstPart && remainingBudget > charsPerPage * 0.2) { - currentChunk += (currentChunk ? '\n\n' : '') + firstPart; - chunks.push(currentChunk.trim()); - currentChunk = ''; - currentEffectiveLength = 0; - - if (secondPart) { - const remainingBlock: Block = { - type: 'list', - content: secondPart, - effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.list), - }; - blocks.splice(i + 1, 0, remainingBlock); - } - } else { - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - } - currentChunk = ''; - currentEffectiveLength = 0; - i--; - } - } else if (block.type === 'code' && block.effectiveLength > charsPerPage) { + if (block.type === 'list' || block.type === 'blockquote' || block.type === 'code') { if (currentChunk.trim()) { chunks.push(currentChunk.trim()); - currentChunk = ''; - currentEffectiveLength = 0; } - const [firstPart, secondPart] = splitCodeBlock(block, charsPerPage); - chunks.push(firstPart.trim()); - - if (secondPart) { - const remainingBlock: Block = { - type: 'code', - content: secondPart, - effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.code), - }; - blocks.splice(i + 1, 0, remainingBlock); + if (block.effectiveLength <= charsPerPage) { + currentChunk = block.content; + currentEffectiveLength = block.effectiveLength; + } else { + if (block.type === 'list') { + const [firstPart, secondPart] = splitList(block, charsPerPage); + chunks.push(firstPart.trim()); + if (secondPart) { + const remainingBlock: Block = { + type: 'list', + content: secondPart, + effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.list), + }; + blocks.splice(i + 1, 0, remainingBlock); + } + currentChunk = ''; + currentEffectiveLength = 0; + } else if (block.type === 'code') { + const [firstPart, secondPart] = splitCodeBlock(block, charsPerPage); + chunks.push(firstPart.trim()); + if (secondPart) { + const remainingBlock: Block = { + type: 'code', + content: secondPart, + effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.code), + }; + blocks.splice(i + 1, 0, remainingBlock); + } + currentChunk = ''; + currentEffectiveLength = 0; + } else { + currentChunk = block.content; + currentEffectiveLength = block.effectiveLength; + } } } else if (block.type === 'paragraph' && block.effectiveLength > charsPerPage) { if (currentChunk.trim()) { From 7a2963703df551f6576971901b763fe7c4974915 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 17:14:16 +0530 Subject: [PATCH 2/9] feat: rewrite pagination to use line-based chunking --- build/defaults/config.yaml | 8 +- build/paginate-pieces.ts | 23 ++- build/utils/markdown-chunker.ts | 284 ++++++++++++++++++++++---------- build/utils/theme-loader.ts | 1 + public/config.yaml | 8 +- 5 files changed, 232 insertions(+), 92 deletions(-) diff --git a/build/defaults/config.yaml b/build/defaults/config.yaml index c144772..ad239a7 100644 --- a/build/defaults/config.yaml +++ b/build/defaults/config.yaml @@ -34,7 +34,13 @@ exclude: pieces: - reader: - charsPerPage: 2200 + columns: 2 + pagination: + columnWidth: 330 + columnHeight: 540 + lineHeight: 24 + avgCharWidth: 8 + safetyMargin: 0.85 order: default: descending rss: diff --git a/build/paginate-pieces.ts b/build/paginate-pieces.ts index ac93f68..3a92037 100644 --- a/build/paginate-pieces.ts +++ b/build/paginate-pieces.ts @@ -2,7 +2,8 @@ import fs from 'fs'; import path from 'path'; import fm from "front-matter"; import yaml from 'js-yaml'; -import { chunkContent } from './utils/markdown-chunker'; +import { chunkContent, getLinesPerPage, getCharsPerLine, PaginationConfig } from './utils/markdown-chunker'; +import { loadTheme } from './utils/theme-loader'; const publicDir = path.join(__dirname, '..', 'public'); const piecesPath = path.join(publicDir, 'content', 'pieces'); @@ -12,7 +13,23 @@ const configPath = path.join(publicDir, 'config.yaml'); const configRaw = fs.readFileSync(configPath, 'utf-8'); const config = yaml.load(configRaw) as any; -const CHARS_PER_PAGE = config?.reader?.charsPerPage ?? 2200; + +// Load theme scale +const themeName = config?.theme || 'journal'; +const theme = loadTheme(themeName); +const themeScale = theme?.font?.scale ?? 1; + +// Build pagination config +const paginationConfig: PaginationConfig = { + columns: config?.reader?.columns ?? 2, + linesPerPage: config?.reader?.linesPerPage, + pagination: config?.reader?.pagination, +}; + +const LINES_PER_PAGE = getLinesPerPage(paginationConfig, themeScale); +const CHARS_PER_LINE = getCharsPerLine(paginationConfig, themeScale); + +console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, linesPerPage=${LINES_PER_PAGE}, charsPerLine=${CHARS_PER_LINE}`); type PiecePage = { pieceSlug: string; @@ -46,7 +63,7 @@ piecesIndex.forEach((piece: any) => { const parsed = fm(raw); const content = parsed.body; - const chunks = chunkContent(content, CHARS_PER_PAGE); + const chunks = chunkContent(content, LINES_PER_PAGE, CHARS_PER_LINE); const totalPages = chunks.length; const pages: PiecePage[] = chunks.map((chunk, index) => ({ diff --git a/build/utils/markdown-chunker.ts b/build/utils/markdown-chunker.ts index 1db9bed..324f4fc 100644 --- a/build/utils/markdown-chunker.ts +++ b/build/utils/markdown-chunker.ts @@ -1,9 +1,13 @@ -export const SCALE_FACTORS = { - list: 1.4, - code: 1.3, - blockquote: 1.2, - heading: 1.0, - paragraph: 1.0, +export type PaginationConfig = { + columns: 1 | 2; + linesPerPage?: number; + pagination?: { + columnWidth?: number; + columnHeight?: number; + lineHeight?: number; + avgCharWidth?: number; + safetyMargin?: number; + }; }; export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph'; @@ -11,32 +15,131 @@ export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph' export type Block = { type: BlockType; content: string; - effectiveLength: number; + estimatedLines: number; +}; + +const DEFAULTS = { + columns: 2 as const, + columnWidth: 330, + columnHeight: 540, + lineHeight: 24, + avgCharWidth: 8, + safetyMargin: 0.85, }; -export function parseMarkdownBlocks(content: string): Block[] { +export function getLinesPerPage(config: PaginationConfig, themeScale: number = 1): number { + if (config.linesPerPage) { + return config.linesPerPage; + } + + const p = config.pagination ?? {}; + const columnWidth = p.columnWidth ?? DEFAULTS.columnWidth; + const columnHeight = p.columnHeight ?? DEFAULTS.columnHeight; + const lineHeight = p.lineHeight ?? DEFAULTS.lineHeight; + const safetyMargin = p.safetyMargin ?? DEFAULTS.safetyMargin; + const columns = config.columns ?? DEFAULTS.columns; + + const adjustedLineHeight = lineHeight * themeScale; + const linesPerColumn = Math.floor(columnHeight / adjustedLineHeight); + const linesPerPage = Math.floor(linesPerColumn * columns * safetyMargin); + + return linesPerPage; +} + +export function getCharsPerLine(config: PaginationConfig, themeScale: number = 1): number { + const p = config.pagination ?? {}; + const columnWidth = p.columnWidth ?? DEFAULTS.columnWidth; + const avgCharWidth = p.avgCharWidth ?? DEFAULTS.avgCharWidth; + + const adjustedCharWidth = avgCharWidth * themeScale; + return Math.floor(columnWidth / adjustedCharWidth); +} + +function estimateParagraphLines(content: string, charsPerLine: number): number { + return Math.ceil(content.length / charsPerLine); +} + +function estimateHeadingLines(content: string, charsPerLine: number): number { + const headingCharsPerLine = Math.floor(charsPerLine * 0.7); + return Math.ceil(content.length / headingCharsPerLine) + 1; +} + +function estimateListLines(content: string, charsPerLine: number): number { + const lines = content.split('\n'); + let totalLines = 0; + + for (const line of lines) { + const effectiveChars = charsPerLine - 4; + totalLines += Math.max(1, Math.ceil(line.length / effectiveChars)); + } + + return totalLines; +} + +function estimateCodeLines(content: string, charsPerLine: number): number { + const lines = content.split('\n'); + let totalLines = 0; + const codeCharsPerLine = Math.floor(charsPerLine * 0.85); + + for (const line of lines) { + totalLines += Math.max(1, Math.ceil(line.length / codeCharsPerLine)); + } + + return totalLines; +} + +function estimateBlockquoteLines(content: string, charsPerLine: number): number { + const quoteCharsPerLine = Math.floor(charsPerLine * 0.9); + const lines = content.split('\n'); + let totalLines = 0; + + for (const line of lines) { + totalLines += Math.max(1, Math.ceil(line.length / quoteCharsPerLine)); + } + + return totalLines + 1; +} + +export function estimateBlockLines(block: Block, charsPerLine: number): number { + switch (block.type) { + case 'heading': + return estimateHeadingLines(block.content, charsPerLine); + case 'list': + return estimateListLines(block.content, charsPerLine); + case 'code': + return estimateCodeLines(block.content, charsPerLine); + case 'blockquote': + return estimateBlockquoteLines(block.content, charsPerLine); + case 'paragraph': + default: + return estimateParagraphLines(block.content, charsPerLine); + } +} + +export function parseMarkdownBlocks(content: string, charsPerLine: number): Block[] { const blocks: Block[] = []; const lines = content.split('\n'); - + let currentBlock: { type: BlockType; lines: string[] } | null = null; let inCodeBlock = false; - + const pushCurrentBlock = () => { if (currentBlock && currentBlock.lines.length > 0) { const blockContent = currentBlock.lines.join('\n'); - const scale = SCALE_FACTORS[currentBlock.type]; - blocks.push({ + const block: Block = { type: currentBlock.type, content: blockContent, - effectiveLength: Math.ceil(blockContent.length * scale), - }); + estimatedLines: 0, + }; + block.estimatedLines = estimateBlockLines(block, charsPerLine); + blocks.push(block); } currentBlock = null; }; - + for (let i = 0; i < lines.length; i++) { const line = lines[i]; - + if (line.trim().startsWith('```')) { if (inCodeBlock) { if (currentBlock) { @@ -52,23 +155,23 @@ export function parseMarkdownBlocks(content: string): Block[] { continue; } } - + if (inCodeBlock) { if (currentBlock) { currentBlock.lines.push(line); } continue; } - + if (line.trim() === '') { pushCurrentBlock(); continue; } - + const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s/); const blockquoteMatch = line.match(/^>\s?/); const headingMatch = line.match(/^#{1,6}\s/); - + if (listMatch) { if (currentBlock?.type === 'list') { currentBlock.lines.push(line); @@ -98,16 +201,16 @@ export function parseMarkdownBlocks(content: string): Block[] { } } } - + pushCurrentBlock(); return blocks; } -export function splitList(block: Block, remainingBudget: number): [string, string] { +function splitListByLines(block: Block, remainingLines: number, charsPerLine: number): [string, string] { const lines = block.content.split('\n'); const items: string[][] = []; let currentItem: string[] = []; - + for (const line of lines) { if (line.match(/^(\s*)([-*+]|\d+\.)\s/) && currentItem.length > 0) { items.push(currentItem); @@ -119,144 +222,151 @@ export function splitList(block: Block, remainingBudget: number): [string, strin if (currentItem.length > 0) { items.push(currentItem); } - - const safetyBuffer = 0.8; - const safeBudget = remainingBudget * safetyBuffer; - - let usedLength = 0; + + let usedLines = 0; let splitIndex = 0; - + const effectiveChars = charsPerLine - 4; + for (let i = 0; i < items.length; i++) { - const itemContent = items[i].join('\n'); - const itemEffectiveLength = Math.ceil(itemContent.length * SCALE_FACTORS.list); - - if (usedLength + itemEffectiveLength > safeBudget) { + let itemLines = 0; + for (const line of items[i]) { + itemLines += Math.max(1, Math.ceil(line.length / effectiveChars)); + } + + if (usedLines + itemLines > remainingLines && i > 0) { break; } - usedLength += itemEffectiveLength; + usedLines += itemLines; splitIndex = i + 1; } - + const firstPart = items.slice(0, splitIndex).map(item => item.join('\n')).join('\n'); const secondPart = items.slice(splitIndex).map(item => item.join('\n')).join('\n'); - + return [firstPart, secondPart]; } -export function splitCodeBlock(block: Block, remainingBudget: number): [string, string] { +function splitCodeByLines(block: Block, remainingLines: number, charsPerLine: number): [string, string] { const lines = block.content.split('\n'); - const isFenced = lines[0]?.trim().startsWith('```'); const fence = isFenced ? lines[0].match(/^(\s*```\w*)/)?.[1] || '```' : ''; - - let usedLength = 0; + + const codeCharsPerLine = Math.floor(charsPerLine * 0.85); + let usedLines = 0; let splitIndex = 0; - + const startIdx = isFenced ? 1 : 0; const endIdx = isFenced && lines[lines.length - 1]?.trim() === '```' ? lines.length - 1 : lines.length; - + for (let i = startIdx; i < endIdx; i++) { - const lineLength = Math.ceil(lines[i].length * SCALE_FACTORS.code); - if (usedLength + lineLength > remainingBudget && i > startIdx) { + const lineCount = Math.max(1, Math.ceil(lines[i].length / codeCharsPerLine)); + if (usedLines + lineCount > remainingLines && i > startIdx) { break; } - usedLength += lineLength; + usedLines += lineCount; splitIndex = i + 1; } - + if (splitIndex <= startIdx) { splitIndex = startIdx + 1; } - + let firstPart: string; let secondPart: string; - + if (isFenced) { firstPart = [lines[0], ...lines.slice(1, splitIndex), '```'].join('\n'); - secondPart = splitIndex < endIdx + secondPart = splitIndex < endIdx ? [fence, ...lines.slice(splitIndex, endIdx), '```'].join('\n') : ''; } else { firstPart = lines.slice(0, splitIndex).join('\n'); secondPart = lines.slice(splitIndex).join('\n'); } - + return [firstPart, secondPart]; } -export function chunkContent(content: string, charsPerPage: number): string[] { - const blocks = parseMarkdownBlocks(content); +export function chunkContent( + content: string, + linesPerPage: number, + charsPerLine: number +): string[] { + const blocks = parseMarkdownBlocks(content, charsPerLine); const chunks: string[] = []; - + let currentChunk = ''; - let currentEffectiveLength = 0; - + let currentLines = 0; + for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; - - if (currentEffectiveLength + block.effectiveLength <= charsPerPage) { + + if (currentLines + block.estimatedLines <= linesPerPage) { currentChunk += (currentChunk ? '\n\n' : '') + block.content; - currentEffectiveLength += block.effectiveLength; + currentLines += block.estimatedLines; continue; } - - if (block.type === 'list' || block.type === 'blockquote' || block.type === 'code') { + + if (block.type === 'list' || block.type === 'code') { if (currentChunk.trim()) { chunks.push(currentChunk.trim()); } - - if (block.effectiveLength <= charsPerPage) { + + if (block.estimatedLines <= linesPerPage) { currentChunk = block.content; - currentEffectiveLength = block.effectiveLength; + currentLines = block.estimatedLines; } else { + const remainingLines = linesPerPage; + if (block.type === 'list') { - const [firstPart, secondPart] = splitList(block, charsPerPage); - chunks.push(firstPart.trim()); - if (secondPart) { + const [firstPart, secondPart] = splitListByLines(block, remainingLines, charsPerLine); + if (firstPart.trim()) { + chunks.push(firstPart.trim()); + } + if (secondPart.trim()) { const remainingBlock: Block = { type: 'list', content: secondPart, - effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.list), + estimatedLines: estimateBlockLines({ type: 'list', content: secondPart, estimatedLines: 0 }, charsPerLine), }; blocks.splice(i + 1, 0, remainingBlock); } currentChunk = ''; - currentEffectiveLength = 0; + currentLines = 0; } else if (block.type === 'code') { - const [firstPart, secondPart] = splitCodeBlock(block, charsPerPage); - chunks.push(firstPart.trim()); - if (secondPart) { + const [firstPart, secondPart] = splitCodeByLines(block, remainingLines, charsPerLine); + if (firstPart.trim()) { + chunks.push(firstPart.trim()); + } + if (secondPart.trim()) { const remainingBlock: Block = { type: 'code', content: secondPart, - effectiveLength: Math.ceil(secondPart.length * SCALE_FACTORS.code), + estimatedLines: estimateBlockLines({ type: 'code', content: secondPart, estimatedLines: 0 }, charsPerLine), }; blocks.splice(i + 1, 0, remainingBlock); } currentChunk = ''; - currentEffectiveLength = 0; - } else { - currentChunk = block.content; - currentEffectiveLength = block.effectiveLength; + currentLines = 0; } } - } else if (block.type === 'paragraph' && block.effectiveLength > charsPerPage) { + } else if (block.type === 'paragraph' && block.estimatedLines > linesPerPage) { if (currentChunk.trim()) { chunks.push(currentChunk.trim()); currentChunk = ''; - currentEffectiveLength = 0; + currentLines = 0; } - + const sentences = block.content.match(/[^.!?]+[.!?]+/g) || [block.content]; for (const sentence of sentences) { - const sentenceLength = sentence.length; - if (currentEffectiveLength + sentenceLength > charsPerPage && currentChunk.trim()) { + const sentenceLines = Math.ceil(sentence.length / charsPerLine); + if (currentLines + sentenceLines > linesPerPage && currentChunk.trim()) { chunks.push(currentChunk.trim()); currentChunk = sentence; - currentEffectiveLength = sentenceLength; + currentLines = sentenceLines; } else { currentChunk += sentence; - currentEffectiveLength += sentenceLength; + currentLines += sentenceLines; } } } else { @@ -264,17 +374,17 @@ export function chunkContent(content: string, charsPerPage: number): string[] { chunks.push(currentChunk.trim()); } currentChunk = block.content; - currentEffectiveLength = block.effectiveLength; + currentLines = block.estimatedLines; } } - + if (currentChunk.trim()) { chunks.push(currentChunk.trim()); } - + if (chunks.length === 0) { chunks.push(content); } - + return chunks; } diff --git a/build/utils/theme-loader.ts b/build/utils/theme-loader.ts index 357b39e..23d42fd 100644 --- a/build/utils/theme-loader.ts +++ b/build/utils/theme-loader.ts @@ -6,6 +6,7 @@ export interface ThemeConfig { family: string; url: string; fallback: string; + scale?: number; }; colors: { light: { diff --git a/public/config.yaml b/public/config.yaml index 077cf38..30ec7fb 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -41,7 +41,13 @@ exclude: pieces: - reader: - charsPerPage: 2200 + columns: 2 + pagination: + columnWidth: 330 + columnHeight: 540 + lineHeight: 24 + avgCharWidth: 8 + safetyMargin: 0.85 order: default: descending Metamorphosis: ascending From 7b1e538faff55fa7da11253b5be8876e16ce90b7 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 17:14:24 +0530 Subject: [PATCH 3/9] refactor: move documentation to docs/ folder --- README.md | 19 ++++++++++--------- ETHOS.md => docs/ETHOS.md | 0 REFERENCES.md => docs/REFERENCES.md | 0 SHOWCASE.md => docs/SHOWCASE.md | 0 THEMING.md => docs/THEMING.md | 0 WRITING.md => docs/WRITING.md | 0 6 files changed, 10 insertions(+), 9 deletions(-) rename ETHOS.md => docs/ETHOS.md (100%) rename REFERENCES.md => docs/REFERENCES.md (100%) rename SHOWCASE.md => docs/SHOWCASE.md (100%) rename THEMING.md => docs/THEMING.md (100%) rename WRITING.md => docs/WRITING.md (100%) diff --git a/README.md b/README.md index c59e85d..5fa9eda 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > An ode to those who love the craft, an ode to the old internet, an ode to a time before numbers and figures dominated writing, an ode to a time where readers remembered their favourite writers, and an ode to the hope that all of it is still present, somewhere. -Ode is for writers who want to publish in an aesthetically pleasing website, who ignore the bells and whistles of the modern internet, and who want to create a better experience for their readers. It is opinionated, minimal, and easy to use, guided by its own [ethos](https://github.com/DeepanshKhurana/ode/blob/main/ETHOS.md). +Ode is for writers who want to publish in an aesthetically pleasing website, who ignore the bells and whistles of the modern internet, and who want to create a better experience for their readers. It is opinionated, minimal, and easy to use, guided by its own [ethos](https://github.com/DeepanshKhurana/ode/blob/main/docs/ETHOS.md). ## Inspiration @@ -22,16 +22,17 @@ You can find a live demo of the app [here](https://demo.ode.dimwit.me/). ## Documentation - **[README.md](https://github.com/DeepanshKhurana/ode/blob/main/README.md)**: Overview, features, and getting started -- **[ETHOS.md](https://github.com/DeepanshKhurana/ode/blob/main/ETHOS.md)**: Core principles and philosophy behind Ode -- **[WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/WRITING.md)**: Content repository, auto-deployment, and GitHub Actions -- **[THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/THEMING.md)**: Theme presets, customization, local fonts, and visual examples +- **[CONFIGURATION.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/CONFIGURATION.md)**: Full config.yaml reference +- **[ETHOS.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/ETHOS.md)**: Core principles and philosophy behind Ode +- **[WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/WRITING.md)**: Content repository, auto-deployment, and GitHub Actions +- **[THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/THEMING.md)**: Theme presets, customization, local fonts, and visual examples - **[CHANGELOG.md](https://github.com/DeepanshKhurana/ode/blob/main/CHANGELOG.md)**: Version history and release notes -- **[REFERENCES.md](https://github.com/DeepanshKhurana/ode/blob/main/REFERENCES.md)**: Credits and inspirations +- **[REFERENCES.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/REFERENCES.md)**: Credits and inspirations ## Screenshots > [!NOTE] -> For theme-specific screenshots, see [THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/THEMING.md) +> For theme-specific screenshots, see [THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/THEMING.md) ### Homepage ![Homepage - Light Mode](.github/media/homepage_light.png) @@ -70,7 +71,7 @@ https://github.com/user-attachments/assets/222af674-11f0-4b5a-8232-a31aca8a61b1 ## Getting Started > [!TIP] -> For detailed notes on how to setup a **content repository** with sync, look into the [WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/WRITING.md) +> For detailed notes on how to setup a **content repository** with sync, look into the [WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/WRITING.md) ### Docker Compose (Recommended) @@ -141,7 +142,7 @@ If you are coming from WordPress, you can use the awesome [lonekorean/wordpress- ## Writing Content > [!TIP] -> A longer guide is in [WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/WRITING.md) +> A longer guide is in [WRITING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/WRITING.md) ### Pieces @@ -177,7 +178,7 @@ Tell everyone everything! ## Theming > [!NOTE] -> For complete theming documentation, including all available presets, customization options, and local font support, see [THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/THEMING.md) +> For complete theming documentation, including all available presets, customization options, and local font support, see [THEMING.md](https://github.com/DeepanshKhurana/ode/blob/main/docs/THEMING.md) Ode comes with 10 built-in themes that you can use and customize. Switch between presets, override colors and fonts, or build your own theme from scratch. diff --git a/ETHOS.md b/docs/ETHOS.md similarity index 100% rename from ETHOS.md rename to docs/ETHOS.md diff --git a/REFERENCES.md b/docs/REFERENCES.md similarity index 100% rename from REFERENCES.md rename to docs/REFERENCES.md diff --git a/SHOWCASE.md b/docs/SHOWCASE.md similarity index 100% rename from SHOWCASE.md rename to docs/SHOWCASE.md diff --git a/THEMING.md b/docs/THEMING.md similarity index 100% rename from THEMING.md rename to docs/THEMING.md diff --git a/WRITING.md b/docs/WRITING.md similarity index 100% rename from WRITING.md rename to docs/WRITING.md From d030c9f436b1e7037979063610e1235bf20484c3 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 17:14:30 +0530 Subject: [PATCH 4/9] feat: add configuration documentation --- docs/CONFIGURATION.md | 183 +++++++++++++++++++++++++++++++++ docsite/docs/configuration.mdx | 10 ++ 2 files changed, 193 insertions(+) create mode 100644 docs/CONFIGURATION.md create mode 100644 docsite/docs/configuration.mdx diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..89f8c8e --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,183 @@ +# Configuration + +Ode uses a `config.yaml` file in `public/` to customize your site. This document covers all available options. + +## Site Settings + +```yaml +site: + title: "My Ode" # Site title + author: "Your Name" # Author name + tagline: "A tagline." # Short tagline + url: "https://example.com" # Production URL (used for RSS/sitemap) + description: "Site description for SEO." +``` + +## Theme + +```yaml +theme: journal # Built-in theme name (journal, comic, doodle, etc.) +``` + +See [THEMING.md](THEMING.md) for full theming documentation. + +## UI Labels + +```yaml +ui: + labels: + home: "Home" + previous: "Previous" + next: "Next" + noContent: "No pieces found in this collection." + errorLoading: "Error loading content." + page: "Page" + of: "of" + volumes: "Reader" + dawn: "Dawn" + dusk: "Dusk" + lightMode: "Switch to light mode" + darkMode: "Switch to dark mode" + randomPiece: "Random Piece" + new: "New" + rss: "RSS Feed" + close: "Close" + lowercase: false # Make all labels lowercase + wordsWasted: "{words} words wasted across {pieces} pieces." +``` + +## Pages + +```yaml +pages: + order: + - about # Order pages appear in navigation + - contact + notFound: obscured # Slug of custom 404 page +``` + +## Exclusions + +```yaml +exclude: + pages: + - draft-page # Hide pages by slug + pieces: + - wip-piece # Hide pieces by slug +``` + +## Reader Mode + +Reader mode paginates content to fit in a two-column book layout. Configuration controls how content is chunked into pages. + +### Basic Options + +```yaml +reader: + columns: 2 # 1 or 2 columns (default: 2) + linesPerPage: 37 # Direct override (optional) + order: + default: descending # "ascending" or "descending" +``` + +### Pagination Calculation + +If `linesPerPage` is not set, it's calculated from these parameters: + +```yaml +reader: + pagination: + columnWidth: 330 # px - width of each column + columnHeight: 540 # px - height (75vh at 720p) + lineHeight: 24 # px - line height + avgCharWidth: 8 # px - average character width + safetyMargin: 0.85 # 0-1 - buffer for overflow prevention +``` + +**Calculation:** +``` +charsPerLine = columnWidth / (avgCharWidth × themeScale) +linesPerColumn = columnHeight / (lineHeight × themeScale) +linesPerPage = linesPerColumn × columns × safetyMargin +``` + +With defaults (scale=1): +- charsPerLine = 330/8 = 41 +- linesPerColumn = 540/24 = 22 +- linesPerPage = 22 × 2 × 0.85 = 37 + +### Troubleshooting + +**Content gets cut off?** +- Lower `linesPerPage` (e.g., 30) +- Or increase `safetyMargin` (e.g., 0.75) + +**Too many pages?** +- Increase `linesPerPage` (e.g., 44) +- Or decrease `safetyMargin` (e.g., 0.9) + +**Using a custom theme with larger font?** +- Theme `scale` is automatically applied +- Or manually set `linesPerPage` to override + +## RSS Feed + +```yaml +rss: + piecesLimit: 10 # Number of pieces in RSS feed +``` + +## Body of Work + +Auto-generated archive page of all pieces. + +```yaml +bodyOfWork: + title: "Body of Work" + slug: "body-of-work" + order: descending # "ascending" or "descending" + description: "A chronological archive of all writings." +``` + +## Example Full Config + +```yaml +site: + title: "My Ode" + author: "Jane Doe" + tagline: "Words, wasted." + url: "https://my-ode.example.com" + description: "A personal writing space." + +theme: journal + +ui: + labels: + volumes: "Library" + lowercase: true + wordsWasted: "{words} words across {pieces} pieces." + +pages: + order: + - about + - body-of-work + notFound: obscured + +exclude: + pieces: + - draft-post + +reader: + columns: 2 + linesPerPage: 37 + order: + default: descending + +rss: + piecesLimit: 20 + +bodyOfWork: + title: "Archive" + slug: "archive" + order: ascending +``` diff --git a/docsite/docs/configuration.mdx b/docsite/docs/configuration.mdx new file mode 100644 index 0000000..b22bab3 --- /dev/null +++ b/docsite/docs/configuration.mdx @@ -0,0 +1,10 @@ +--- +title: Configuration +hide_title: true +sidebar_label: Configuration +sidebar_position: 3 +--- + +import Content from '@site/../docs/CONFIGURATION.md'; + + From 91ae5bb9829d67d766e609e1b1775e04511a6ed6 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 17:14:35 +0530 Subject: [PATCH 5/9] chore: update docsite imports for new docs location --- docsite/docs/changelog.mdx | 2 +- docsite/docs/ethos.mdx | 2 +- docsite/docs/license.mdx | 2 +- docsite/docs/references.mdx | 4 ++-- docsite/docs/showcase.mdx | 4 ++-- docsite/docs/theming.mdx | 4 ++-- docsite/docs/writing.mdx | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docsite/docs/changelog.mdx b/docsite/docs/changelog.mdx index f11940a..3b6163b 100644 --- a/docsite/docs/changelog.mdx +++ b/docsite/docs/changelog.mdx @@ -2,7 +2,7 @@ title: Changelog hide_title: true sidebar_label: Changelog -sidebar_position: 7 +sidebar_position: 8 --- import Content from '@site/../CHANGELOG.md'; diff --git a/docsite/docs/ethos.mdx b/docsite/docs/ethos.mdx index e4464ec..91dc930 100644 --- a/docsite/docs/ethos.mdx +++ b/docsite/docs/ethos.mdx @@ -5,6 +5,6 @@ sidebar_label: Ethos sidebar_position: 2 --- -import Content from '@site/../ETHOS.md'; +import Content from '@site/../docs/ETHOS.md'; diff --git a/docsite/docs/license.mdx b/docsite/docs/license.mdx index eca624d..c7b9579 100644 --- a/docsite/docs/license.mdx +++ b/docsite/docs/license.mdx @@ -2,7 +2,7 @@ title: License hide_title: true sidebar_label: License -sidebar_position: 8 +sidebar_position: 9 --- import LICENSE from '@site/../LICENSE'; diff --git a/docsite/docs/references.mdx b/docsite/docs/references.mdx index 2ee4068..89eb170 100644 --- a/docsite/docs/references.mdx +++ b/docsite/docs/references.mdx @@ -2,9 +2,9 @@ title: References hide_title: true sidebar_label: References -sidebar_position: 6 +sidebar_position: 7 --- -import Content from '@site/../REFERENCES.md'; +import Content from '@site/../docs/REFERENCES.md'; diff --git a/docsite/docs/showcase.mdx b/docsite/docs/showcase.mdx index 6475265..5b2f419 100644 --- a/docsite/docs/showcase.mdx +++ b/docsite/docs/showcase.mdx @@ -2,9 +2,9 @@ title: Showcase hide_title: true sidebar_label: Showcase -sidebar_position: 5 +sidebar_position: 6 --- -import Content from '@site/../SHOWCASE.md'; +import Content from '@site/../docs/SHOWCASE.md'; diff --git a/docsite/docs/theming.mdx b/docsite/docs/theming.mdx index 323fa6d..dc24b4c 100644 --- a/docsite/docs/theming.mdx +++ b/docsite/docs/theming.mdx @@ -2,9 +2,9 @@ title: Theming hide_title: true sidebar_label: Theming -sidebar_position: 4 +sidebar_position: 5 --- -import Content from '@site/../THEMING.md'; +import Content from '@site/../docs/THEMING.md'; diff --git a/docsite/docs/writing.mdx b/docsite/docs/writing.mdx index 9c30ace..e320b9c 100644 --- a/docsite/docs/writing.mdx +++ b/docsite/docs/writing.mdx @@ -2,9 +2,9 @@ title: Writing hide_title: true sidebar_label: Writing -sidebar_position: 3 +sidebar_position: 4 --- -import Content from '@site/../WRITING.md'; +import Content from '@site/../docs/WRITING.md'; From b98c13445584bf310ac9c299deef4d10c5d86deb Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 18:08:02 +0530 Subject: [PATCH 6/9] refactor: improve paragraph chunking logic in content chunker --- build/paginate-pieces.ts | 12 +- build/utils/markdown-chunker.ts | 438 ++++++++++++++------------------ 2 files changed, 197 insertions(+), 253 deletions(-) diff --git a/build/paginate-pieces.ts b/build/paginate-pieces.ts index 3a92037..7891716 100644 --- a/build/paginate-pieces.ts +++ b/build/paginate-pieces.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import fm from "front-matter"; import yaml from 'js-yaml'; -import { chunkContent, getLinesPerPage, getCharsPerLine, PaginationConfig } from './utils/markdown-chunker'; +import { chunkContent, getCharsPerPage, PaginationConfig } from './utils/markdown-chunker'; import { loadTheme } from './utils/theme-loader'; const publicDir = path.join(__dirname, '..', 'public'); @@ -14,22 +14,18 @@ const configPath = path.join(publicDir, 'config.yaml'); const configRaw = fs.readFileSync(configPath, 'utf-8'); const config = yaml.load(configRaw) as any; -// Load theme scale const themeName = config?.theme || 'journal'; const theme = loadTheme(themeName); const themeScale = theme?.font?.scale ?? 1; -// Build pagination config const paginationConfig: PaginationConfig = { columns: config?.reader?.columns ?? 2, - linesPerPage: config?.reader?.linesPerPage, pagination: config?.reader?.pagination, }; -const LINES_PER_PAGE = getLinesPerPage(paginationConfig, themeScale); -const CHARS_PER_LINE = getCharsPerLine(paginationConfig, themeScale); +const CHARS_PER_PAGE = getCharsPerPage(paginationConfig, themeScale); -console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, linesPerPage=${LINES_PER_PAGE}, charsPerLine=${CHARS_PER_LINE}`); +console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, charsPerPage=${CHARS_PER_PAGE}`); type PiecePage = { pieceSlug: string; @@ -63,7 +59,7 @@ piecesIndex.forEach((piece: any) => { const parsed = fm(raw); const content = parsed.body; - const chunks = chunkContent(content, LINES_PER_PAGE, CHARS_PER_LINE); + const chunks = chunkContent(content, CHARS_PER_PAGE); const totalPages = chunks.length; const pages: PiecePage[] = chunks.map((chunk, index) => ({ diff --git a/build/utils/markdown-chunker.ts b/build/utils/markdown-chunker.ts index 324f4fc..47dcf84 100644 --- a/build/utils/markdown-chunker.ts +++ b/build/utils/markdown-chunker.ts @@ -1,6 +1,6 @@ export type PaginationConfig = { columns: 1 | 2; - linesPerPage?: number; + charsPerPage?: number; pagination?: { columnWidth?: number; columnHeight?: number; @@ -15,7 +15,6 @@ export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph' export type Block = { type: BlockType; content: string; - estimatedLines: number; }; const DEFAULTS = { @@ -25,188 +24,132 @@ const DEFAULTS = { lineHeight: 24, avgCharWidth: 8, safetyMargin: 0.85, + blockOverhead: 50, }; -export function getLinesPerPage(config: PaginationConfig, themeScale: number = 1): number { - if (config.linesPerPage) { - return config.linesPerPage; +export function getCharsPerPage(config: PaginationConfig, themeScale: number = 1): number { + if (config.charsPerPage) { + return config.charsPerPage; } const p = config.pagination ?? {}; const columnWidth = p.columnWidth ?? DEFAULTS.columnWidth; const columnHeight = p.columnHeight ?? DEFAULTS.columnHeight; const lineHeight = p.lineHeight ?? DEFAULTS.lineHeight; + const avgCharWidth = p.avgCharWidth ?? DEFAULTS.avgCharWidth; const safetyMargin = p.safetyMargin ?? DEFAULTS.safetyMargin; const columns = config.columns ?? DEFAULTS.columns; const adjustedLineHeight = lineHeight * themeScale; - const linesPerColumn = Math.floor(columnHeight / adjustedLineHeight); - const linesPerPage = Math.floor(linesPerColumn * columns * safetyMargin); - - return linesPerPage; -} - -export function getCharsPerLine(config: PaginationConfig, themeScale: number = 1): number { - const p = config.pagination ?? {}; - const columnWidth = p.columnWidth ?? DEFAULTS.columnWidth; - const avgCharWidth = p.avgCharWidth ?? DEFAULTS.avgCharWidth; - const adjustedCharWidth = avgCharWidth * themeScale; - return Math.floor(columnWidth / adjustedCharWidth); -} - -function estimateParagraphLines(content: string, charsPerLine: number): number { - return Math.ceil(content.length / charsPerLine); -} - -function estimateHeadingLines(content: string, charsPerLine: number): number { - const headingCharsPerLine = Math.floor(charsPerLine * 0.7); - return Math.ceil(content.length / headingCharsPerLine) + 1; -} - -function estimateListLines(content: string, charsPerLine: number): number { - const lines = content.split('\n'); - let totalLines = 0; - - for (const line of lines) { - const effectiveChars = charsPerLine - 4; - totalLines += Math.max(1, Math.ceil(line.length / effectiveChars)); - } - - return totalLines; -} - -function estimateCodeLines(content: string, charsPerLine: number): number { - const lines = content.split('\n'); - let totalLines = 0; - const codeCharsPerLine = Math.floor(charsPerLine * 0.85); - - for (const line of lines) { - totalLines += Math.max(1, Math.ceil(line.length / codeCharsPerLine)); - } - - return totalLines; -} - -function estimateBlockquoteLines(content: string, charsPerLine: number): number { - const quoteCharsPerLine = Math.floor(charsPerLine * 0.9); - const lines = content.split('\n'); - let totalLines = 0; - for (const line of lines) { - totalLines += Math.max(1, Math.ceil(line.length / quoteCharsPerLine)); - } + const linesPerColumn = Math.floor(columnHeight / adjustedLineHeight); + const charsPerLine = Math.floor(columnWidth / adjustedCharWidth); + const charsPerPage = Math.floor(linesPerColumn * charsPerLine * columns * safetyMargin); - return totalLines + 1; + return charsPerPage; } -export function estimateBlockLines(block: Block, charsPerLine: number): number { - switch (block.type) { - case 'heading': - return estimateHeadingLines(block.content, charsPerLine); - case 'list': - return estimateListLines(block.content, charsPerLine); - case 'code': - return estimateCodeLines(block.content, charsPerLine); - case 'blockquote': - return estimateBlockquoteLines(block.content, charsPerLine); - case 'paragraph': - default: - return estimateParagraphLines(block.content, charsPerLine); - } -} - -export function parseMarkdownBlocks(content: string, charsPerLine: number): Block[] { +function parseBlocks(content: string): Block[] { const blocks: Block[] = []; const lines = content.split('\n'); - let currentBlock: { type: BlockType; lines: string[] } | null = null; + let current: { type: BlockType; lines: string[] } | null = null; let inCodeBlock = false; - const pushCurrentBlock = () => { - if (currentBlock && currentBlock.lines.length > 0) { - const blockContent = currentBlock.lines.join('\n'); - const block: Block = { - type: currentBlock.type, - content: blockContent, - estimatedLines: 0, - }; - block.estimatedLines = estimateBlockLines(block, charsPerLine); - blocks.push(block); + const push = () => { + if (current && current.lines.length > 0) { + blocks.push({ type: current.type, content: current.lines.join('\n') }); } - currentBlock = null; + current = null; }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - + for (const line of lines) { if (line.trim().startsWith('```')) { if (inCodeBlock) { - if (currentBlock) { - currentBlock.lines.push(line); - } - pushCurrentBlock(); + current?.lines.push(line); + push(); inCodeBlock = false; - continue; } else { - pushCurrentBlock(); + push(); inCodeBlock = true; - currentBlock = { type: 'code', lines: [line] }; - continue; + current = { type: 'code', lines: [line] }; } + continue; } if (inCodeBlock) { - if (currentBlock) { - currentBlock.lines.push(line); - } + current?.lines.push(line); continue; } if (line.trim() === '') { - pushCurrentBlock(); + push(); continue; } - const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s/); - const blockquoteMatch = line.match(/^>\s?/); - const headingMatch = line.match(/^#{1,6}\s/); - - if (listMatch) { - if (currentBlock?.type === 'list') { - currentBlock.lines.push(line); + if (line.match(/^(\s*)([-*+]|\d+\.)\s/)) { + if (current?.type === 'list') { + current.lines.push(line); } else { - pushCurrentBlock(); - currentBlock = { type: 'list', lines: [line] }; + push(); + current = { type: 'list', lines: [line] }; } - } else if (blockquoteMatch) { - if (currentBlock?.type === 'blockquote') { - currentBlock.lines.push(line); + } else if (line.match(/^>\s?/)) { + if (current?.type === 'blockquote') { + current.lines.push(line); } else { - pushCurrentBlock(); - currentBlock = { type: 'blockquote', lines: [line] }; + push(); + current = { type: 'blockquote', lines: [line] }; } - } else if (headingMatch) { - pushCurrentBlock(); - currentBlock = { type: 'heading', lines: [line] }; - pushCurrentBlock(); + } else if (line.match(/^#{1,6}\s/)) { + push(); + blocks.push({ type: 'heading', content: line }); } else { - if (currentBlock?.type === 'list' && line.match(/^\s+/)) { - currentBlock.lines.push(line); - } else if (currentBlock?.type === 'paragraph') { - currentBlock.lines.push(line); + if (current?.type === 'list' && line.match(/^\s+/)) { + current.lines.push(line); + } else if (current?.type === 'blockquote') { + current.lines.push(line); + } else if (current?.type === 'paragraph') { + current.lines.push(line); } else { - pushCurrentBlock(); - currentBlock = { type: 'paragraph', lines: [line] }; + push(); + current = { type: 'paragraph', lines: [line] }; } } } - pushCurrentBlock(); + push(); return blocks; } -function splitListByLines(block: Block, remainingLines: number, charsPerLine: number): [string, string] { +function findSplitPoint(text: string, maxLen: number): number { + if (text.length <= maxLen) return text.length; + + const sentenceBreaks = ['. ', '? ', '! ']; + for (const brk of sentenceBreaks) { + const idx = text.lastIndexOf(brk, maxLen); + if (idx > maxLen * 0.3) return idx + brk.length; + } + + const clauseBreaks = [', ', '; ', ': ']; + for (const brk of clauseBreaks) { + const idx = text.lastIndexOf(brk, maxLen); + if (idx > maxLen * 0.3) return idx + brk.length; + } + + const spaceIdx = text.lastIndexOf(' ', maxLen); + if (spaceIdx > maxLen * 0.3) return spaceIdx + 1; + + return maxLen; +} + +function splitParagraph(block: Block, budget: number): [string, string] { + const splitAt = findSplitPoint(block.content, budget); + return [block.content.slice(0, splitAt).trim(), block.content.slice(splitAt).trim()]; +} + +function splitList(block: Block, budget: number): [string, string] { const lines = block.content.split('\n'); const items: string[][] = []; let currentItem: string[] = []; @@ -219,162 +162,167 @@ function splitListByLines(block: Block, remainingLines: number, charsPerLine: nu currentItem.push(line); } } - if (currentItem.length > 0) { - items.push(currentItem); - } + if (currentItem.length > 0) items.push(currentItem); - let usedLines = 0; - let splitIndex = 0; - const effectiveChars = charsPerLine - 4; + let used = 0; + let splitIdx = 0; for (let i = 0; i < items.length; i++) { - let itemLines = 0; - for (const line of items[i]) { - itemLines += Math.max(1, Math.ceil(line.length / effectiveChars)); - } - - if (usedLines + itemLines > remainingLines && i > 0) { - break; - } - usedLines += itemLines; - splitIndex = i + 1; + const itemLen = items[i].join('\n').length + DEFAULTS.blockOverhead; + if (used + itemLen > budget && i > 0) break; + used += itemLen; + splitIdx = i + 1; } - const firstPart = items.slice(0, splitIndex).map(item => item.join('\n')).join('\n'); - const secondPart = items.slice(splitIndex).map(item => item.join('\n')).join('\n'); + if (splitIdx === 0) splitIdx = 1; - return [firstPart, secondPart]; + const first = items.slice(0, splitIdx).map(i => i.join('\n')).join('\n'); + const second = items.slice(splitIdx).map(i => i.join('\n')).join('\n'); + return [first, second]; } -function splitCodeByLines(block: Block, remainingLines: number, charsPerLine: number): [string, string] { +function splitCode(block: Block, budget: number): [string, string] { const lines = block.content.split('\n'); const isFenced = lines[0]?.trim().startsWith('```'); - const fence = isFenced ? lines[0].match(/^(\s*```\w*)/)?.[1] || '```' : ''; - - const codeCharsPerLine = Math.floor(charsPerLine * 0.85); - let usedLines = 0; - let splitIndex = 0; + const fence = isFenced ? lines[0] : '```'; const startIdx = isFenced ? 1 : 0; const endIdx = isFenced && lines[lines.length - 1]?.trim() === '```' ? lines.length - 1 : lines.length; + let used = fence.length * 2; + let splitIdx = startIdx; + for (let i = startIdx; i < endIdx; i++) { - const lineCount = Math.max(1, Math.ceil(lines[i].length / codeCharsPerLine)); - if (usedLines + lineCount > remainingLines && i > startIdx) { - break; - } - usedLines += lineCount; - splitIndex = i + 1; + if (used + lines[i].length > budget && i > startIdx) break; + used += lines[i].length; + splitIdx = i + 1; } - if (splitIndex <= startIdx) { - splitIndex = startIdx + 1; - } + if (splitIdx <= startIdx) splitIdx = startIdx + 1; - let firstPart: string; - let secondPart: string; - - if (isFenced) { - firstPart = [lines[0], ...lines.slice(1, splitIndex), '```'].join('\n'); - secondPart = splitIndex < endIdx - ? [fence, ...lines.slice(splitIndex, endIdx), '```'].join('\n') - : ''; - } else { - firstPart = lines.slice(0, splitIndex).join('\n'); - secondPart = lines.slice(splitIndex).join('\n'); - } + const firstLines = isFenced + ? [fence, ...lines.slice(startIdx, splitIdx), '```'] + : lines.slice(0, splitIdx); + const secondLines = splitIdx < endIdx + ? (isFenced ? [fence, ...lines.slice(splitIdx, endIdx), '```'] : lines.slice(splitIdx)) + : []; + + return [firstLines.join('\n'), secondLines.join('\n')]; +} + +function splitBlockquote(block: Block, budget: number): [string, string] { + const rawContent = block.content + .split('\n') + .map(l => l.replace(/^>\s?/, '')) + .join(' ') + .trim(); - return [firstPart, secondPart]; + const splitAt = findSplitPoint(rawContent, budget); + const firstText = rawContent.slice(0, splitAt).trim(); + const secondText = rawContent.slice(splitAt).trim(); + + const first = firstText ? '> ' + firstText : ''; + const second = secondText ? '> ' + secondText : ''; + + return [first, second]; +} + +function splitBlock(block: Block, budget: number): [string, string] { + switch (block.type) { + case 'list': + return splitList(block, budget); + case 'code': + return splitCode(block, budget); + case 'blockquote': + return splitBlockquote(block, budget); + case 'paragraph': + default: + return splitParagraph(block, budget); + } } -export function chunkContent( - content: string, - linesPerPage: number, - charsPerLine: number -): string[] { - const blocks = parseMarkdownBlocks(content, charsPerLine); +export function chunkContent(content: string, charsPerPage: number): string[] { + const blocks = parseBlocks(content); const chunks: string[] = []; let currentChunk = ''; - let currentLines = 0; + let currentLen = 0; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; + const blockLen = block.content.length + DEFAULTS.blockOverhead; - if (currentLines + block.estimatedLines <= linesPerPage) { + if (currentLen + blockLen <= charsPerPage) { currentChunk += (currentChunk ? '\n\n' : '') + block.content; - currentLines += block.estimatedLines; + currentLen += blockLen; continue; } - if (block.type === 'list' || block.type === 'code') { - if (currentChunk.trim()) { + if (block.type === 'heading') { + if (currentChunk) { chunks.push(currentChunk.trim()); } + currentChunk = block.content; + currentLen = blockLen; + continue; + } - if (block.estimatedLines <= linesPerPage) { - currentChunk = block.content; - currentLines = block.estimatedLines; - } else { - const remainingLines = linesPerPage; - - if (block.type === 'list') { - const [firstPart, secondPart] = splitListByLines(block, remainingLines, charsPerLine); - if (firstPart.trim()) { - chunks.push(firstPart.trim()); - } - if (secondPart.trim()) { - const remainingBlock: Block = { - type: 'list', - content: secondPart, - estimatedLines: estimateBlockLines({ type: 'list', content: secondPart, estimatedLines: 0 }, charsPerLine), - }; - blocks.splice(i + 1, 0, remainingBlock); - } - currentChunk = ''; - currentLines = 0; - } else if (block.type === 'code') { - const [firstPart, secondPart] = splitCodeByLines(block, remainingLines, charsPerLine); - if (firstPart.trim()) { - chunks.push(firstPart.trim()); - } - if (secondPart.trim()) { - const remainingBlock: Block = { - type: 'code', - content: secondPart, - estimatedLines: estimateBlockLines({ type: 'code', content: secondPart, estimatedLines: 0 }, charsPerLine), - }; - blocks.splice(i + 1, 0, remainingBlock); - } - currentChunk = ''; - currentLines = 0; - } - } - } else if (block.type === 'paragraph' && block.estimatedLines > linesPerPage) { - if (currentChunk.trim()) { + const remainingBudget = charsPerPage - currentLen - DEFAULTS.blockOverhead; + + if (remainingBudget > 100 && block.type !== 'heading') { + const [first, second] = splitBlock(block, remainingBudget); + + if (first && first.trim()) { + currentChunk += (currentChunk ? '\n\n' : '') + first; chunks.push(currentChunk.trim()); currentChunk = ''; - currentLines = 0; - } - - const sentences = block.content.match(/[^.!?]+[.!?]+/g) || [block.content]; - for (const sentence of sentences) { - const sentenceLines = Math.ceil(sentence.length / charsPerLine); - if (currentLines + sentenceLines > linesPerPage && currentChunk.trim()) { - chunks.push(currentChunk.trim()); - currentChunk = sentence; - currentLines = sentenceLines; - } else { - currentChunk += sentence; - currentLines += sentenceLines; + currentLen = 0; + + if (second && second.trim()) { + let remaining = second; + let remainingType = block.type; + + while (remaining.length > 0) { + const remLen = remaining.length + DEFAULTS.blockOverhead; + if (remLen <= charsPerPage) { + currentChunk = remaining; + currentLen = remLen; + break; + } + const [part, rest] = splitBlock({ type: remainingType, content: remaining }, charsPerPage); + if (part) chunks.push(part.trim()); + remaining = rest; + if (!remaining) break; + } } + continue; } + } + + if (currentChunk) { + chunks.push(currentChunk.trim()); + } + + if (blockLen <= charsPerPage) { + currentChunk = block.content; + currentLen = blockLen; } else { - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); + currentChunk = ''; + currentLen = 0; + + let remaining = block.content; + let remainingType = block.type; + + while (remaining.length > 0) { + const [first, second] = splitBlock({ type: remainingType, content: remaining }, charsPerPage); + + if (first) { + chunks.push(first.trim()); + } + + remaining = second; + if (!remaining) break; } - currentChunk = block.content; - currentLines = block.estimatedLines; } } From be559fe7b26c2ee31981c2b912549250210c7267 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 18:19:20 +0530 Subject: [PATCH 7/9] fix: css changes for ensuring content flows smoothly --- src/components/BookViewer/BookViewer.scss | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/BookViewer/BookViewer.scss b/src/components/BookViewer/BookViewer.scss index 9f953a1..db267ed 100644 --- a/src/components/BookViewer/BookViewer.scss +++ b/src/components/BookViewer/BookViewer.scss @@ -72,8 +72,8 @@ } blockquote, pre, ul, ol, h1, h2, h3, h4, h5, h6 { - break-inside: avoid; - page-break-inside: avoid; + break-inside: auto; + page-break-inside: auto; } p, blockquote, pre, ul, ol, h1, h2, h3, h4, h5, h6 { @@ -86,13 +86,6 @@ page-break-inside: auto; } - &.allow-breaks { - blockquote, pre, ul, ol { - break-inside: auto; - page-break-inside: auto; - } - } - .piece-header { display: flex; justify-content: space-between; From 2854af54a70d63bea11df7ec726d5d00722a6021 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 18:22:19 +0530 Subject: [PATCH 8/9] fix: logging for paginate-pieces.ts --- build/paginate-pieces.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build/paginate-pieces.ts b/build/paginate-pieces.ts index 7891716..0f18da3 100644 --- a/build/paginate-pieces.ts +++ b/build/paginate-pieces.ts @@ -14,9 +14,10 @@ const configPath = path.join(publicDir, 'config.yaml'); const configRaw = fs.readFileSync(configPath, 'utf-8'); const config = yaml.load(configRaw) as any; -const themeName = config?.theme || 'journal'; +const themeName = config?.ui?.theme?.preset || config?.theme || 'journal'; const theme = loadTheme(themeName); -const themeScale = theme?.font?.scale ?? 1; +const themeScaleOverride = config?.ui?.theme?.overrides?.font?.scale; +const themeScale = themeScaleOverride ?? theme?.font?.scale ?? 1; const paginationConfig: PaginationConfig = { columns: config?.reader?.columns ?? 2, From 5b467f97fd6e4948153a98a0febc6ff8ed7ffb5e Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 21:53:00 +0530 Subject: [PATCH 9/9] chore: update version to 1.5.0 --- CHANGELOG.md | 16 ++++++++++++---- docsite/docusaurus.config.ts | 2 +- docsite/package.json | 2 +- package.json | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9dad7..e24d1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.4.6] - 2026-03-06 +## [1.5.0] - 2026-03-06 ### Added -- Configurable characters per page in reader mode via `reader.charsPerPage` in `config.yaml`. Users may not need to change this at all. But the idea is to keep it configurable in case there are some visual artifacts in the reader mode. This is responsible for splitting the pages for the reader mode. +- Configurable pagination parameters via `reader.pagination` in `config.yaml` with `columnWidth`, `columnHeight`, `lineHeight`, `avgCharWidth`, and `safetyMargin` options. + +### Changed + +- **Breaking**: Rewrote pagination algorithm from line-based to character-based for more accurate page breaks. +- CSS columns now allow content to break inside blocks (`break-inside: auto`) for natural text flow. +- Theme scale overrides (`ui.theme.overrides.font.scale`) now affect pagination calculations. ### Fixed -- Reader mode now correctly paginates lists, code blocks, and blockquotes instead of truncating them mid-element. -- Body of Work page now appears on first deploy when using a custom slug; reliably. +- Theme config path now correctly reads `ui.theme.preset` instead of top-level `theme`. +- Reader mode no longer overflows or cuts off content at column boundaries. +- Blockquotes and lists split mid-content when needed instead of jumping entirely to next column. +- Body of Work page now appears on first deploy when using a custom slug. ## [1.4.5] - 2026-03-05 diff --git a/docsite/docusaurus.config.ts b/docsite/docusaurus.config.ts index a066100..d866151 100644 --- a/docsite/docusaurus.config.ts +++ b/docsite/docusaurus.config.ts @@ -123,7 +123,7 @@ const config: Config = { { type: 'html', position: 'right', - value: 'v1.4.6', + value: 'v1.5.0', }, { href: 'https://demo.ode.dimwit.me/', diff --git a/docsite/package.json b/docsite/package.json index af8c67e..3b53f19 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -1,6 +1,6 @@ { "name": "docsite", - "version": "1.4.6", + "version": "1.5.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/package.json b/package.json index 38b5428..47b5134 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ode", "private": true, - "version": "1.4.6", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite",