From 83027a18d9ddabc91c160185ea94b9655c8da3f9 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 15:41:10 +0530 Subject: [PATCH 1/4] feat: add configurable reader.charsPerPage option --- build/defaults/config.yaml | 1 + public/config.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/build/defaults/config.yaml b/build/defaults/config.yaml index b667b39..c144772 100644 --- a/build/defaults/config.yaml +++ b/build/defaults/config.yaml @@ -34,6 +34,7 @@ exclude: pieces: - reader: + charsPerPage: 2200 order: default: descending rss: diff --git a/public/config.yaml b/public/config.yaml index 21fccb0..077cf38 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -41,6 +41,7 @@ exclude: pieces: - reader: + charsPerPage: 2200 order: default: descending Metamorphosis: ascending From 5a69a227ebc1c9697f1158fefc4dbea892270d81 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 15:41:23 +0530 Subject: [PATCH 2/4] fix: make pagination markdown-aware to prevent element truncation --- build/paginate-pieces.ts | 51 +----- build/utils/markdown-chunker.ts | 286 ++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 44 deletions(-) create mode 100644 build/utils/markdown-chunker.ts diff --git a/build/paginate-pieces.ts b/build/paginate-pieces.ts index 06dd26a..ac93f68 100644 --- a/build/paginate-pieces.ts +++ b/build/paginate-pieces.ts @@ -1,13 +1,18 @@ import fs from 'fs'; import path from 'path'; import fm from "front-matter"; +import yaml from 'js-yaml'; +import { chunkContent } from './utils/markdown-chunker'; const publicDir = path.join(__dirname, '..', 'public'); const piecesPath = path.join(publicDir, 'content', 'pieces'); const pagesIndexPath = path.join(publicDir, 'generated', 'index', 'pieces-pages.json'); const piecesIndexPath = path.join(publicDir, 'generated', 'index', 'pieces.json'); +const configPath = path.join(publicDir, 'config.yaml'); -const CHARS_PER_PAGE = 2200; +const configRaw = fs.readFileSync(configPath, 'utf-8'); +const config = yaml.load(configRaw) as any; +const CHARS_PER_PAGE = config?.reader?.charsPerPage ?? 2200; type PiecePage = { pieceSlug: string; @@ -23,49 +28,6 @@ type PiecePageIndex = { }; }; -function chunkContent(content: string, charsPerPage: number): string[] { - const chunks: string[] = []; - const paragraphs = content.split('\n\n'); - - let currentChunk = ''; - - for (const paragraph of paragraphs) { - const trimmedParagraph = paragraph.trim(); - if (!trimmedParagraph) continue; - - if (currentChunk.length > 0 && (currentChunk.length + trimmedParagraph.length + 2) > charsPerPage) { - chunks.push(currentChunk.trim()); - currentChunk = ''; - } - - if (trimmedParagraph.length > charsPerPage) { - const sentences = trimmedParagraph.match(/[^.!?]+[.!?]+/g) || [trimmedParagraph]; - - for (const sentence of sentences) { - if (currentChunk.length > 0 && (currentChunk.length + sentence.length) > charsPerPage) { - chunks.push(currentChunk.trim()); - currentChunk = sentence; - } else { - currentChunk += sentence; - } - } - currentChunk += '\n\n'; - } else { - currentChunk += trimmedParagraph + '\n\n'; - } - } - - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - } - - if (chunks.length === 0) { - chunks.push(content); - } - - return chunks; -} - const piecesIndexRaw = fs.readFileSync(piecesIndexPath, 'utf-8'); const piecesIndex = JSON.parse(piecesIndexRaw); @@ -104,3 +66,4 @@ piecesIndex.forEach((piece: any) => { fs.writeFileSync(pagesIndexPath, JSON.stringify(pageIndex, null, 2)); console.log(`[pagination]: created pieces-pages.json with ${Object.keys(pageIndex).length} pieces`); + diff --git a/build/utils/markdown-chunker.ts b/build/utils/markdown-chunker.ts new file mode 100644 index 0000000..15dab9f --- /dev/null +++ b/build/utils/markdown-chunker.ts @@ -0,0 +1,286 @@ +export const SCALE_FACTORS = { + list: 1.4, + code: 1.3, + blockquote: 1.2, + heading: 1.0, + paragraph: 1.0, +}; + +export type BlockType = 'list' | 'code' | 'blockquote' | 'heading' | 'paragraph'; + +export type Block = { + type: BlockType; + content: string; + effectiveLength: number; +}; + +export function parseMarkdownBlocks(content: string): 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({ + type: currentBlock.type, + content: blockContent, + effectiveLength: Math.ceil(blockContent.length * scale), + }); + } + currentBlock = null; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.trim().startsWith('```')) { + if (inCodeBlock) { + if (currentBlock) { + currentBlock.lines.push(line); + } + pushCurrentBlock(); + inCodeBlock = false; + continue; + } else { + pushCurrentBlock(); + inCodeBlock = true; + currentBlock = { type: 'code', lines: [line] }; + 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); + } else { + pushCurrentBlock(); + currentBlock = { type: 'list', lines: [line] }; + } + } else if (blockquoteMatch) { + if (currentBlock?.type === 'blockquote') { + currentBlock.lines.push(line); + } else { + pushCurrentBlock(); + currentBlock = { type: 'blockquote', lines: [line] }; + } + } else if (headingMatch) { + pushCurrentBlock(); + currentBlock = { type: 'heading', lines: [line] }; + pushCurrentBlock(); + } else { + if (currentBlock?.type === 'list' && line.match(/^\s+/)) { + currentBlock.lines.push(line); + } else if (currentBlock?.type === 'paragraph') { + currentBlock.lines.push(line); + } else { + pushCurrentBlock(); + currentBlock = { type: 'paragraph', lines: [line] }; + } + } + } + + pushCurrentBlock(); + return blocks; +} + +export function splitList(block: Block, remainingBudget: 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); + currentItem = [line]; + } else { + currentItem.push(line); + } + } + if (currentItem.length > 0) { + items.push(currentItem); + } + + let usedLength = 0; + let splitIndex = 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; + } + + 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]; +} + +export function splitCodeBlock(block: Block, remainingBudget: 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 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) { + break; + } + usedLength += lineLength; + 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 + ? [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); + const chunks: string[] = []; + + let currentChunk = ''; + let currentEffectiveLength = 0; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + + if (currentEffectiveLength + block.effectiveLength <= charsPerPage) { + currentChunk += (currentChunk ? '\n\n' : '') + block.content; + currentEffectiveLength += block.effectiveLength; + 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()) { + 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); + } + } else if (block.type === 'paragraph' && block.effectiveLength > charsPerPage) { + if (currentChunk.trim()) { + 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; + } + } + } else { + if (currentChunk.trim()) { + chunks.push(currentChunk.trim()); + } + currentChunk = block.content; + currentEffectiveLength = block.effectiveLength; + } + } + + if (currentChunk.trim()) { + chunks.push(currentChunk.trim()); + } + + if (chunks.length === 0) { + chunks.push(content); + } + + return chunks; +} From e2fb11cf3657bfce176a95529e10a6b8e92ade05 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 15:41:37 +0530 Subject: [PATCH 3/4] fix: generate body-of-work before indexing pages --- build/generate-body-of-work.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/generate-body-of-work.ts b/build/generate-body-of-work.ts index ebac890..6cc489e 100644 --- a/build/generate-body-of-work.ts +++ b/build/generate-body-of-work.ts @@ -15,6 +15,8 @@ const config = yaml.load(fs.readFileSync(configPath, 'utf-8')) as { }; }; +const bodyOfWorkSlug = config?.bodyOfWork?.slug || 'body-of-work'; + type Piece = { slug: string; title: string; @@ -73,7 +75,6 @@ const sortedKeys = Object.keys(grouped).sort((a, b) => { }); const bodyOfWorkTitle = config?.bodyOfWork?.title || 'Body of Work'; -const bodyOfWorkSlug = config?.bodyOfWork?.slug || 'body-of-work'; const bodyOfWorkFilePath = path.join(publicDir, 'content', 'pages', `${bodyOfWorkSlug}.md`); let markdown = '---\n'; From 3ef965e396ca7dff35ab1d623033ee27754485e8 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Fri, 6 Mar 2026 15:42:10 +0530 Subject: [PATCH 4/4] chore: release v1.4.6 --- CHANGELOG.md | 11 +++++++++++ docsite/docusaurus.config.ts | 2 +- docsite/package.json | 2 +- package.json | 4 ++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf1947..4a9dad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 + +### 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. + +### 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. + ## [1.4.5] - 2026-03-05 ### Fixed diff --git a/docsite/docusaurus.config.ts b/docsite/docusaurus.config.ts index a730b4a..a066100 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.5', + value: 'v1.4.6', }, { href: 'https://demo.ode.dimwit.me/', diff --git a/docsite/package.json b/docsite/package.json index a49ef23..af8c67e 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -1,6 +1,6 @@ { "name": "docsite", - "version": "1.4.5", + "version": "1.4.6", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/package.json b/package.json index eda57d4..38b5428 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "ode", "private": true, - "version": "1.4.5", + "version": "1.4.6", "type": "module", "scripts": { "dev": "vite", "build": "npm run build:index && vite build", - "build:index": "vite-node build/ensure-defaults.ts && vite-node build/generate-502-page.ts && vite-node build/index-pieces.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-body-of-work.ts && vite-node build/generate-meta-pages.ts", + "build:index": "vite-node build/ensure-defaults.ts && vite-node build/generate-502-page.ts && vite-node build/index-pieces.ts && vite-node build/generate-body-of-work.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-meta-pages.ts", "build:pieces": "vite-node build/index-pieces.ts", "build:pages": "vite-node build/index-pages.ts", "build:paginate": "vite-node build/paginate-pieces.ts",