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/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

@@ -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/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..0f18da3 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, getCharsPerPage, 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,20 @@ 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;
+
+const themeName = config?.ui?.theme?.preset || config?.theme || 'journal';
+const theme = loadTheme(themeName);
+const themeScaleOverride = config?.ui?.theme?.overrides?.font?.scale;
+const themeScale = themeScaleOverride ?? theme?.font?.scale ?? 1;
+
+const paginationConfig: PaginationConfig = {
+ columns: config?.reader?.columns ?? 2,
+ pagination: config?.reader?.pagination,
+};
+
+const CHARS_PER_PAGE = getCharsPerPage(paginationConfig, themeScale);
+
+console.log(`[pagination]: theme=${themeName}, scale=${themeScale}, charsPerPage=${CHARS_PER_PAGE}`);
type PiecePage = {
pieceSlug: string;
diff --git a/build/utils/markdown-chunker.ts b/build/utils/markdown-chunker.ts
index 15dab9f..47dcf84 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;
+ charsPerPage?: number;
+ pagination?: {
+ columnWidth?: number;
+ columnHeight?: number;
+ lineHeight?: number;
+ avgCharWidth?: number;
+ safetyMargin?: number;
+ };
};
export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph';
@@ -11,103 +15,145 @@ export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph'
export type Block = {
type: BlockType;
content: string;
- effectiveLength: number;
};
-export function parseMarkdownBlocks(content: string): Block[] {
+const DEFAULTS = {
+ columns: 2 as const,
+ columnWidth: 330,
+ columnHeight: 540,
+ lineHeight: 24,
+ avgCharWidth: 8,
+ safetyMargin: 0.85,
+ blockOverhead: 50,
+};
+
+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 adjustedCharWidth = avgCharWidth * themeScale;
+
+ const linesPerColumn = Math.floor(columnHeight / adjustedLineHeight);
+ const charsPerLine = Math.floor(columnWidth / adjustedCharWidth);
+ const charsPerPage = Math.floor(linesPerColumn * charsPerLine * columns * safetyMargin);
+
+ return charsPerPage;
+}
+
+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 scale = SCALE_FACTORS[currentBlock.type];
- blocks.push({
- type: currentBlock.type,
- content: blockContent,
- effectiveLength: Math.ceil(blockContent.length * scale),
- });
+
+ 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;
}
-export function splitList(block: Block, remainingBudget: 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[] = [];
-
+
for (const line of lines) {
if (line.match(/^(\s*)([-*+]|\d+\.)\s/) && currentItem.length > 0) {
items.push(currentItem);
@@ -116,171 +162,177 @@ export function splitList(block: Block, remainingBudget: number): [string, strin
currentItem.push(line);
}
}
- if (currentItem.length > 0) {
- items.push(currentItem);
- }
-
- let usedLength = 0;
- let splitIndex = 0;
-
+ if (currentItem.length > 0) items.push(currentItem);
+
+ let used = 0;
+ let splitIdx = 0;
+
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 > remainingBudget && i > 0) {
- break;
- }
- usedLength += itemEffectiveLength;
- splitIndex = i + 1;
+ const itemLen = items[i].join('\n').length + DEFAULTS.blockOverhead;
+ if (used + itemLen > budget && i > 0) break;
+ used += itemLen;
+ splitIdx = 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');
-
- return [firstPart, secondPart];
+
+ if (splitIdx === 0) splitIdx = 1;
+
+ 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];
}
-export function splitCodeBlock(block: Block, remainingBudget: 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] || '```' : '';
-
- let usedLength = 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 lineLength = Math.ceil(lines[i].length * SCALE_FACTORS.code);
- if (usedLength + lineLength > remainingBudget && i > startIdx) {
- break;
- }
- usedLength += lineLength;
- splitIndex = i + 1;
- }
-
- if (splitIndex <= startIdx) {
- splitIndex = startIdx + 1;
+ if (used + lines[i].length > budget && i > startIdx) break;
+ used += lines[i].length;
+ splitIdx = i + 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');
+
+ if (splitIdx <= startIdx) splitIdx = startIdx + 1;
+
+ 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();
+
+ 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);
}
-
- return [firstPart, secondPart];
}
export function chunkContent(content: string, charsPerPage: number): string[] {
- const blocks = parseMarkdownBlocks(content);
+ const blocks = parseBlocks(content);
const chunks: string[] = [];
-
+
let currentChunk = '';
- let currentEffectiveLength = 0;
-
+ let currentLen = 0;
+
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
-
- if (currentEffectiveLength + block.effectiveLength <= charsPerPage) {
+ const blockLen = block.content.length + DEFAULTS.blockOverhead;
+
+ if (currentLen + blockLen <= charsPerPage) {
currentChunk += (currentChunk ? '\n\n' : '') + block.content;
- currentEffectiveLength += block.effectiveLength;
+ currentLen += blockLen;
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 (currentChunk.trim()) {
+
+ if (block.type === 'heading') {
+ if (currentChunk) {
chunks.push(currentChunk.trim());
- currentChunk = '';
- currentEffectiveLength = 0;
}
+ currentChunk = block.content;
+ currentLen = blockLen;
+ continue;
+ }
+
+ const remainingBudget = charsPerPage - currentLen - DEFAULTS.blockOverhead;
+
+ if (remainingBudget > 100 && block.type !== 'heading') {
+ const [first, second] = splitBlock(block, remainingBudget);
- 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);
- }
- } else if (block.type === 'paragraph' && block.effectiveLength > charsPerPage) {
- if (currentChunk.trim()) {
+ if (first && first.trim()) {
+ currentChunk += (currentChunk ? '\n\n' : '') + first;
chunks.push(currentChunk.trim());
currentChunk = '';
- currentEffectiveLength = 0;
- }
-
- const sentences = block.content.match(/[^.!?]+[.!?]+/g) || [block.content];
- for (const sentence of sentences) {
- const sentenceLength = sentence.length;
- if (currentEffectiveLength + sentenceLength > charsPerPage && currentChunk.trim()) {
- chunks.push(currentChunk.trim());
- currentChunk = sentence;
- currentEffectiveLength = sentenceLength;
- } else {
- currentChunk += sentence;
- currentEffectiveLength += sentenceLength;
+ 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;
- currentEffectiveLength = block.effectiveLength;
}
}
-
+
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/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/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
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/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';
+
+
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';
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",
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
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;