From 2c856bb4c5de1826536f071325959afb14f58342 Mon Sep 17 00:00:00 2001 From: Ma'sum Date: Sun, 30 Aug 2026 14:43:36 +0700 Subject: [PATCH] feat: implement TOC page breaks, improve watermark token interpolation, and enhance cover/back cover styling configurations --- .../cli/markforge/docs/full-all-properties.md | 387 ++++++++++++++---- apps/cli/markforge/markforge.full-config.ts | 42 +- .../src/content/docs/markforge/changelog.mdx | 32 ++ .../content/docs/markforge/configuration.mdx | 20 +- packages/markforge/README.md | 4 +- packages/markforge/package.json | 2 +- packages/markforge/src/cli.tsx | 16 +- .../markforge/src/config/resolveConfig.ts | 190 ++++++--- packages/markforge/src/config/types.ts | 42 +- .../markforge/src/core/docx/docxBuilder.ts | 24 +- .../markforge/src/core/html/htmlBuilder.ts | 79 ++-- .../markforge/src/core/html/htmlThemes.ts | 22 +- packages/markforge/src/core/parser.ts | 20 +- packages/markforge/src/core/pdf/pdfBuilder.ts | 37 +- packages/markforge/src/index.ts | 2 +- packages/markforge/src/ui/App.tsx | 5 +- packages/markforge/src/ui/Header.tsx | 93 ++++- packages/markforge/src/version.ts | 13 +- .../markforge/test/enterpriseFeatures.test.ts | 58 ++- 19 files changed, 868 insertions(+), 220 deletions(-) diff --git a/apps/cli/markforge/docs/full-all-properties.md b/apps/cli/markforge/docs/full-all-properties.md index 730fc54..169e060 100644 --- a/apps/cli/markforge/docs/full-all-properties.md +++ b/apps/cli/markforge/docs/full-all-properties.md @@ -1,6 +1,6 @@ --- title: "Unified Platform Architecture & Document Suite" -subtitle: "Enterprise Specification & Feature Validation Guide" +subtitle: "Enterprise Specification, API Reference & System Architecture" author: "Ma'sum" company: "Masum Dev Technologies" version: "1.0.0" @@ -10,124 +10,369 @@ lang: "en" # Executive Summary -This sample document validates **100% of all properties** supported by **MarkForge Document Engine**: +The **MarkForge Document Engine** provides high-throughput, cross-platform document publishing for mission-critical enterprise systems. By abstracting the complexities of Microsoft Word OpenXML, CSS Paged Media standards, and standalone HTML5 bundles, MarkForge enables uniform document generation from declarative Markdown specifications. -- **Metadata & Dynamic Tokens**: Title, Subtitle, Author, Date, Version, Company in headers and footers. -- **Table of Contents (TOC)**: Automatically extracted from Markdown heading hierarchy. -- **Watermark Engine**: Custom angle, opacity, font size, and color. -- **Syntax Highlighting**: Theme-aware tokenizer applied to multi-language code snippets. -- **Visual Callouts**: GitHub-style alert callouts rendered with border and background styling. -- **Tables & Typography**: Formatted tables with text alignment. +This specification document outlines the comprehensive system architecture, API contracts, security mechanisms, mathematical foundations, and benchmarking metrics. + +- **Dynamic Token Interpolation**: Header, footer, and body token replacement (`{title}`, `{version}`, `{author}`, `{company}`, `{date}`, `{metakuda}`). +- **Multi-Target Compilation**: Concurrent generation of DOCX, PDF, and HTML artifacts from a unified Abstract Syntax Tree. +- **Enterprise PDF Security**: ISO 32000-2 standard AES-256 encryption with granular permission flags. +- **Hierarchical Heading Numbering**: Config-driven decimal prefixing for multi-tier headings. --- -# Architecture Pipeline +# System Architecture & Processing Pipeline + +The MarkForge compilation engine operates as a multi-stage compilation pipeline. The source document is ingested into an Abstract Syntax Tree (AST), enriched with metadata cascading rules, and dispatched to target format builders. + +## Input Ingestion & Lexical Analysis + +The input reader normalizes line terminators (`\r\n` to `\n`), strips byte order marks (BOM), and extracts YAML/JSON frontmatter blocks. + +## AST Parsing & Directive Resolution + +Custom container directives such as `:::columns` and GitHub-style alerts `> [!NOTE]` are parsed into dedicated AST node structures. -MarkForge transforms Markdown directly into professional Word documents, print-ready PDFs, and responsive web pages. +## Metadata Normalization & Token Binding + +Configuration sources are merged following a 3-tier hierarchy: Frontmatter > Config File > Default Engine Settings. + +## Compilation Pipeline Flow + +The workflow below illustrates the progression of an input source through parsing, token normalization, and multi-format binary synthesis: ```mermaid -graph LR - MD["Markdown (.md)"] --> AST["MarkForge AST"] - AST --> DOCX["DOCX Document"] - AST --> PDF["Chromium PDF Engine"] - AST --> HTML["Self-Contained HTML"] +graph TD + A["Markdown Source File (.md)"] --> B["AST Parser & Directive Resolver"] + C["Project Config (markforge.config.ts)"] --> D["Configuration Normalizer"] + B --> E["Enriched Document AST"] + D --> E + E --> F["DOCX Generator (OpenXML)"] + E --> G["PDF Generator (Chromium Paged Media)"] + E --> H["HTML5 Standalone Bundler"] + F --> I["Target DOCX (.docx)"] + G --> J["Target PDF (.pdf)"] + H --> K["Target HTML (.html)"] +``` + +## Security & Encryption Architecture + +When PDF security is enabled, the output stream is passed through an AES-256 cipher before file persistence: + +```mermaid +sequenceDiagram + autonumber + participant Client as Client Application / CLI + participant Engine as MarkForge Compiler + participant Chromium as Headless Chromium Engine + participant Security as ISO 32000-2 Security Layer + participant Storage as File System Output + + Client->>Engine: compileMarkdown(doc, config) + Engine->>Chromium: Ingest HTML + CSS Paged Media + Chromium-->>Engine: Raw Vector PDF Buffer + Engine->>Security: Apply User & Owner Passwords (AES-256) + Security->>Security: Enforce Permissions (Print, Copy, Annotate) + Security-->>Engine: Encrypted PDF Stream + Engine->>Storage: Write Artifacts (DOCX, PDF, HTML) + Engine-->>Client: Return Output Manifest & Performance Metrics ``` --- -# Multi-Language Code Blocks +# Document Format Engines & Serialization -The code blocks below are styled according to the configured `syntaxTheme`: +MarkForge features dedicated, independent serialization engines for each target format to guarantee pixel perfection across platforms. -```typescript -import { compileMarkdown } from "@masumdev/markforge"; - -export async function generateReleaseNotes(): Promise { - const result = await compileMarkdown({ - content: "# Release v1.0.0\n\nAll features verified.", - config: { - to: ["docx", "pdf", "html"], - theme: "corporate", - syntaxTheme: "dracula", +## Microsoft Word OpenXML Engine + +The DOCX builder compiles AST nodes directly into native OpenXML primitives without intermediate HTML converters. + +Font families, twip metric sizes, paragraph spacings, and run-level bold/italic formats are mapped directly to Microsoft Word styles. Cover pages and Table of Contents utilize isolated sections to prevent header/footer leakage into introductory pages. + +## Chromium Vector PDF Engine + +The PDF engine harnesses headless Chromium with CSS Paged Media standards for high-fidelity vector rendering. + +Dimensions, margins, and running page counters are configured using standard `@page` rules with `@top-left`, `@top-right`, `@bottom-left`, and `@bottom-right` margin boxes. Watermarks are rendered to transparent PNG buffers and stamped as PDF Image XObjects via `pdf-lib` to eliminate selectable text tokens. + +## Standalone HTML5 Bundler + +The HTML engine bundles CSS styles, syntax themes, math assets, and base64-encoded media into a single self-contained `.html` file. Local files, SVG vector diagrams, and KaTeX fonts are converted into embedded data URIs with zero external network dependencies. + +--- + +# REST API & Programmatic Interface Specification + +MarkForge exposes both high-level orchestrator APIs and granular AST generation endpoints. + +## Endpoint: Initiate Compilation Job + +### HTTP Request Specification + +`POST /api/v1/compile` + +### Authentication & Headers + +| Header Name | Type | Description | +| :--- | :--- | :--- | +| `Authorization` | `string` | Bearer token authentication header | +| `Content-Type` | `string` | `application/json` | +| `X-Client-Version` | `string` | Client SDK version identifier | + +### Request Payload Schema + +```json +{ + "source": { + "content": "# Project Specification\n\nAutomated documentation pipeline.", + "format": "markdown", + "baseDir": "/workspace/docs" + }, + "options": { + "targets": ["docx", "pdf", "html"], + "theme": "corporate", + "paperSize": "a4", + "orientation": "portrait", + "numberHeadings": { + "enabled": true, + "depth": 4, + "skipH1": true }, - }); - console.log(`Generated ${result.files.length} output files.`); + "security": { + "userPassword": "secureUserPass123", + "ownerPassword": "masterAdminPass456", + "permissions": { + "printing": "highResolution", + "modifying": false, + "copying": true, + "annotating": true + } + } + } } ``` -```python -import os +### Response Payload Schema + +```json +{ + "status": "success", + "jobId": "job_mf_9823412093", + "durationMs": 482, + "artifacts": [ + { + "format": "docx", + "fileName": "specification.docx", + "sizeBytes": 334028, + "checksumSha256": "8f4b23a9e1029c..." + }, + { + "format": "pdf", + "fileName": "specification.pdf", + "sizeBytes": 476582, + "checksumSha256": "1a92e8c049182d..." + }, + { + "format": "html", + "fileName": "specification.html", + "sizeBytes": 886490, + "checksumSha256": "4b76a01293e81a..." + } + ] +} +``` + +## Endpoint: Validate Configuration File + +`POST /api/v1/config/validate` + +Returns validation status, detected deprecations, and normalized configuration tokens. + +## TypeScript SDK Programmatic Interface + +```typescript +import { + compileMarkdown, + defineConfig, + OutputFormat, + Theme, + PaperSizeEnum, + type CompilationResult, +} from "@masumdev/markforge"; + +export async function executeDocumentBuild(): Promise { + const config = defineConfig({ + to: [OutputFormat.DOCX, OutputFormat.PDF, OutputFormat.HTML], + theme: Theme.CORPORATE, + paperSize: PaperSizeEnum.A4, + margins: { + top: "3cm", + bottom: "2.5cm", + left: "2.5cm", + right: "2.5cm", + }, + numberHeadings: { + enabled: true, + depth: 3, + skipH1: false, + }, + }); -def check_environment(): - api_key = os.getenv("API_KEY") - if not api_key: - raise ValueError("Missing API key configuration") - return {"status": "ready", "env": "production"} + return await compileMarkdown({ + filePath: "./docs/full-all-properties.md", + config, + }); +} ``` --- -# Callout Verifications +# Callout Directives & Visual Annotations + +MarkForge natively formats GitHub-style callout alert directives with custom theme-tailored borders, icons, and background tints across all output formats. + +## Note Callout Semantics > [!NOTE] -> All running header tokens (`{title}`, `{author}`, `{version}`) are evaluated dynamically at compile time. +> All running header tokens such as `{title}`, `{author}`, `{company}`, and `{version}` are dynamically evaluated during AST resolution. + +## Tip Callout Semantics > [!TIP] -> Use `--watch` flag during local authoring for instant live updates. +> Use the `--serve` CLI flag during local development to start an interactive live-reload preview server with scroll synchronization. + +## Important Callout Semantics > [!IMPORTANT] -> Both Word Twips and CSS `@page` dimensions are accurately synchronized.## Heading 2### Heading 3 -| Column 1 | Column 2 | Column 3 | -| :--- | :---: | ---: | -| Data A | Data B | Data C | -| Data D | Data E | Data F | +> Both Microsoft Word OpenXML twips and CSS `@page` paged media dimensions are synchronized down to sub-pixel accuracy. + +## Warning Callout Semantics -| Column 1 | Column 2 | Column 3 | -| :--- | :---: | ---: | -| Data A | Data B | Data C | -| Data D | Data E | Data F | +> [!WARNING] +> When applying PDF security with empty `userPassword`, permissions protection is active while permitting unrestricted document reading. -| Column 1 | Column 2 | Column 3 | -| :--- | :---: | ---: | -| Data A | Data B | Data C | -| Data D | Data E | Data F | +## Caution Callout Semantics +> [!CAUTION] +> Deleting or overwriting master encryption keys without backing up `ownerPassword` prevents future permission modifications. --- -# Mathematical Foundations +# Mathematical Formulations & Quantum Physics + +MarkForge includes embedded KaTeX support without requiring external browser scripts. Both inline expressions and multi-line display blocks render seamlessly in PDF, HTML, and Microsoft Word. + +## Inline Physics Notation & Momentum + +Quantum wave-particle duality is governed by the de Broglie relation $\lambda = \frac{h}{p}$, where $h$ is Planck's constant and $p$ represents particle momentum. In relativistic electrodynamics, energy-momentum invariance is given by $E^2 = (pc)^2 + (m_0 c^2)^2$. -MarkForge provides native KaTeX math compilation for both inline physics equations such as $E = mc^2$ and complex display block proofs[^euler]: +## Maxwell-Ampere Differential Law -$$\oint_C \mathbf{B} \cdot d\mathbf{l} = \mu_0 \left( I_{\text{enc}} + \varepsilon_0 \frac{d\Phi_E}{dt} \right)$$ +The generalized Maxwell-Ampere relation describing the electromagnetic induction field is formulated as follows[^maxwell]: + +$$\oint_C \mathbf{B} \cdot d\mathbf{l} = \mu_0 \iint_S \mathbf{J} \cdot d\mathbf{A} + \mu_0 \varepsilon_0 \frac{d}{dt} \iint_S \mathbf{E} \cdot d\mathbf{A}$$ + +## Multi-Variable Optimization Matrix + +For optimization functions $f: \mathbb{R}^n \to \mathbb{R}$, the Hessian matrix $H(f)$ and Taylor expansion are expressed as: + +$$f(\mathbf{x} + \Delta \mathbf{x}) \approx f(\mathbf{x}) + \nabla f(\mathbf{x})^T \Delta \mathbf{x} + \frac{1}{2} \Delta \mathbf{x}^T \mathbf{H}(f) \Delta \mathbf{x}$$ + +## Schrödinger Wave Mechanics + +The time-dependent Schrödinger equation governing non-relativistic quantum states is defined by: + +$$i\hbar \frac{\partial}{\partial t} \Psi(\mathbf{r}, t) = \left[ -\frac{\hbar^2}{2m}\nabla^2 + V(\mathbf{r}, t) \right] \Psi(\mathbf{r}, t)$$ --- -# Multi-Column Comparison +# Structural Container Directives & Layouts + +The `:::columns` directive generates multi-column grid layouts in HTML/PDF and borderless multi-cell table structures in DOCX. + +## Dual Column Enterprise Architecture :::columns 2 :::col -### Cloud Architecture -- Globally distributed edge routing -- Low-latency WebSocket connections -- Automatic failover clustering +### Cloud-Native Microservices +- Distributed ingress routing via Traefik edge proxies. +- Zero-trust service mesh with automated mTLS encryption. +- Horizontal pod autoscaling based on throughput metrics. +- Global content delivery network caching. ::: :::col -### On-Premises Security -- Air-gapped deployment support -- Hardware Security Module (HSM) keys -- Strict local audit logging +### Enterprise Security & Compliance +- Air-gapped deployment compatibility for defense networks. +- FIPS 140-3 validated Hardware Security Module (HSM) keys. +- Real-time audit trail aggregation and SIEM forwarding. +- Role-based access control (RBAC) with LDAP synchronization. ::: ::: +## Triple Column Feature Matrix + +:::columns 3 +:::col +### High Availability +- Multi-region replication. +- Sub-second failover. +- Automated health checks. +::: +:::col +### Scalability +- Sharded data pipelines. +- Dynamic worker pools. +- Redis stream queues. +::: +:::col +### Observability +- OpenTelemetry metrics. +- Distributed tracing. +- Structured JSON logs. +::: +::: + +--- + +# Quality Assurance & Verification Checklists + +The development pipeline enforces a strict verification lifecycle across all packages: + +## Static Analysis & Type Safety Checks + +- [x] Zero explicit `any` type annotations across all TypeScript files. +- [x] Zero unused imports, variables, or functions. +- [x] Strict TypeScript AST validation via TSCheck engine. + +## Document Rendering & Layout Audits + +- [x] Watermark suppression verified on Front Cover and Back Cover. +- [x] Formal academic page numbering: Lowercase Roman for TOC, decimal reset for body. +- [x] Table of Contents isolated via hard page breaks. +- [ ] End-to-end integration benchmark with 10,000-page enterprise catalogs. + --- -# Benchmark Metrics +# Performance Benchmark Metrics + +The table below summarizes average compilation throughput across document sizes measured on an AMD Ryzen 9 7950X workstation: + +## Multi-Format Throughput Matrix + +| Document Size (Pages) | Word DOCX (ms) | Vector PDF (ms) | Standalone HTML (ms) | Peak RAM (MB) | +| :--- | :---: | :---: | :---: | :---: | +| 1 - 10 Pages | 42 ms | 520 ms | 18 ms | 48 MB | +| 11 - 50 Pages | 115 ms | 1,240 ms | 45 ms | 82 MB | +| 51 - 200 Pages | 380 ms | 3,180 ms | 120 ms | 164 MB | +| 201 - 1,000 Pages | 1,420 ms | 11,850 ms | 410 ms | 340 MB | + +## Memory Footprint & Resource Utilization + +Garbage collection profiles remain stable under continuous batch execution with zero memory leaks. + +--- -| Pipeline Component | Target Output | Processing Time | Quality Level | -| :--- | :--- | :---: | :---: | -| Native DOCX Packager | `full-all-properties.docx` | 45ms | Pixel-Perfect Word Layout | -| Headless Chromium | `full-all-properties.pdf` | 650ms | 300 DPI Vector PDF | -| HTML5 Bundler | `full-all-properties.html` | 18ms | Single Self-Contained File | +# References & Citations -[^euler]: Maxwell, J. C. (1865). A Dynamical Theory of the Electromagnetic Field. Philosophical Transactions of the Royal Society of London. +[^maxwell]: Maxwell, J. C. (1865). A Dynamical Theory of the Electromagnetic Field. Philosophical Transactions of the Royal Society of London, 155, 459-512. diff --git a/apps/cli/markforge/markforge.full-config.ts b/apps/cli/markforge/markforge.full-config.ts index 63ab842..c7bdd1d 100644 --- a/apps/cli/markforge/markforge.full-config.ts +++ b/apps/cli/markforge/markforge.full-config.ts @@ -138,24 +138,24 @@ export default defineConfig({ math: true, // PDF Document Security & Password Encryption - security: { - userPassword: "masumdev_secret", // Password required to open document - ownerPassword: "masumdev_admin", // Master password required to modify permissions - permissions: { - printing: "highResolution", // "highResolution" | "lowResolution" | false - modifying: false, - copying: true, - annotating: true, - fillingForms: true, - contentAccessibility: true, - documentAssembly: false, - }, - }, + // security: { + // userPassword: "masumdev_secret", // Password required to open document + // ownerPassword: "masumdev_admin", // Master password required to modify permissions + // permissions: { + // printing: "highResolution", // "highResolution" | "lowResolution" | false + // modifying: false, + // copying: true, + // annotating: true, + // fillingForms: true, + // contentAccessibility: true, + // documentAssembly: false, + // }, + // }, // security: undefined, // Omit or leave undefined for unrestricted standard PDF // Watermark configuration (using WatermarkPosition enum or strings) watermark: { - text: "CONFIDENTIAL DRAFT", + text: "{metakuda}", color: "#E11D48", opacity: 0.1, fontSize: 52, @@ -211,14 +211,15 @@ export default defineConfig({ company: "{company}", address: "Jakarta, Indonesia", email: "contact@masumdev.com", - phone: "+62 812 3456 7890", + // phone: "+62 812 3456 7890", website: "https://react-native-library-docs.netlify.app", - social: { - github: "https://github.com/masumrpg", - // twitter: "https://x.com/masumdev", - // linkedin: "https://linkedin.com/in/masumdev", - }, + // social: { + // github: "https://github.com/masumrpg", + // // twitter: "https://x.com/masumdev", + // // linkedin: "https://linkedin.com/in/masumdev", + // }, copyright: "Copyright (c) {year} {company}. All Rights Reserved.", + // bgGradient: "#fff", }, // backCover: false, // Set to false to disable back cover closing page @@ -231,6 +232,7 @@ export default defineConfig({ version: "1.0.0", date: "2026-08-29", lang: "en", + metakuda: "Kuda" }, // Custom external stylesheet path(s) to inject into HTML and PDF documents diff --git a/apps/docs/src/content/docs/markforge/changelog.mdx b/apps/docs/src/content/docs/markforge/changelog.mdx index 3d2b49d..981b165 100644 --- a/apps/docs/src/content/docs/markforge/changelog.mdx +++ b/apps/docs/src/content/docs/markforge/changelog.mdx @@ -6,6 +6,38 @@ sidebar: description: Release history and version notes for @masumdev/markforge. --- +## [0.4.1] - 2026-08-30 + +### Enhancements, Security, CLI, and Configuration Refinements + +- **Watermark Suppression on Front & Back Covers**: + - Excluded the vector/PNG watermark overlay from both the Front Cover Page (page 0) and the Back Cover Closing Page (final page) in PDF generation. + - Suppressed the HTML fixed canvas watermark in print media (`@media print { .document-watermark { display: none !important; } }`) to ensure only explicit `pdf-lib` page indexing controls watermark rendering. +- **Symmetric & Unified Cover & Back Cover Styling Configuration**: + - Unified configuration property names across `CoverPageConfig` and `BackCoverConfig` for complete API consistency. + - Added support for `backgroundColor`, `bgGradient`, `textColor`, `titleColor`, `subtitleColor`, `accentColor`, `badge`, `badgeColor`, `badgeTextColor`, `logo`, `logoWidth`, `footerText`, `copyright`, `address`, `email`, `phone`, `website`, and `social` across front and back covers in HTML, PDF, and DOCX. +- **Heading Color Harmonization (`h1` through `h6`)**: + - Unified all heading levels (`h1`, `h2`, `h3`, `h4`, `h5`, `h6`) to use brand primary dark styling (`var(--mf-primary-dark)`) across HTML, PDF, and DOCX for visual consistency. + - `h1`: 2.5px solid primary border underline; `h2`: 1px subtle teal border underline. +- **Strict Package.json Version Resolution**: + - Removed fallback version constant in `version.ts`. The engine now resolves version strictly from the nearest `@masumdev/markforge` `package.json` at runtime and throws an explicit error if missing or unreadable. +- **Comprehensive Terminal CLI Parameter & Configuration Banner**: + - Ink CLI header box and Live Preview banner (`--serve`) now display all resolved configuration details: source path, config path, visual theme, syntax highlighter, layout, output directory, and active module list (TOC, Numbering, Cover, BackCover, Watermark, Security, Math, Syntax). +- **Arbitrary Dynamic Metadata & Nested Record Token Interpolation**: + - Enhanced `replaceDocumentTokens` to dynamically unpack and replace tokens defined in top-level `metadata` or nested `metadata` dictionaries (e.g. `{metakuda}` or `{custom_key}`). + - Fixed AST parser inline text chunking to merge adjacent plain text spans, ensuring token names containing underscores (`_`) or hyphens are not split across separate tokens. +- **Table of Contents (TOC) Page Break Isolation**: + - Table of Contents is now fully isolated from subsequent document body content via `page-break-after: always; break-after: page;` in PDF/HTML and a dedicated section break in Microsoft Word DOCX. +- **Academic & Enterprise Page Numbering Schema**: + - Front Cover: Unnumbered, running headers/footers suppressed. + - Table of Contents: Formal lowercase Roman numerals (`i`, `ii`, ...). + - Main Document Body: Counter reset (`counter-reset: page 1;`) with standard decimal numbers (`1, 2, ...`). + - Back Cover: Unnumbered, isolated final closing section. +- **Config-Driven Section Numbering (`numberHeadings`)**: + - Propagated top-level configuration options (`numberHeadings: { enabled, depth, skipH1, prefix }`) directly into AST parsing and compilation across HTML, PDF, and DOCX. + +--- + ## [0.4.0] - 2026-08-29 ### Major Enterprise Features & Security Release diff --git a/apps/docs/src/content/docs/markforge/configuration.mdx b/apps/docs/src/content/docs/markforge/configuration.mdx index c5dd04d..05ab48f 100644 --- a/apps/docs/src/content/docs/markforge/configuration.mdx +++ b/apps/docs/src/content/docs/markforge/configuration.mdx @@ -308,7 +308,7 @@ numberHeadings: { ### Cover Page Builder (`coverPage`) -Generates a dedicated, unnumbered title page before the main document content: +Generates a dedicated, unnumbered title page before the main document content with full custom styling support: ```typescript coverPage: { @@ -326,6 +326,12 @@ coverPage: { badgeColor: "#ECFDFD", // Badge background color badgeTextColor: "#0D998D", // Badge text color footerText: "Strictly Confidential", // Bottom disclaimer text + backgroundColor: "#0A192F", // Optional custom background color + bgGradient: "linear-gradient(135deg, #0A192F 0%, #172A45 100%)", // Custom CSS gradient + titleColor: "#FFFFFF", // Custom title color + subtitleColor: "#64FFDA", // Custom subtitle color + textColor: "#8892B0", // Custom text color + accentColor: "#64FFDA", // Custom accent color } ``` @@ -333,7 +339,7 @@ coverPage: { ### Back Cover / Closing Page (`backCover`) -Generates an isolated closing back cover card at the end of the document: +Generates an isolated closing back cover card at the end of the document with symmetric styling properties matching `coverPage`: ```typescript backCover: { @@ -352,6 +358,12 @@ backCover: { github: "https://github.com/masumrpg", }, copyright: "Copyright (c) {year} {company}. All Rights Reserved.", + backgroundColor: "#0A192F", // Custom background color + bgGradient: "linear-gradient(135deg, #0A192F 0%, #172A45 100%)", // Custom gradient + titleColor: "#FFFFFF", // Custom title color + subtitleColor: "#64FFDA", // Custom subtitle color + textColor: "#8892B0", // Custom text color + accentColor: "#64FFDA", // Custom accent color } ``` @@ -399,12 +411,12 @@ watermark: { --- -### Signatures & Approval Block (`signatureBlock`) +### Signatures & Approval Block (`signatures`) Appends a formal multi-signatory approval grid to the document: ```typescript -signatureBlock: { +signatures: { align: SignatureAlign.SPACE_BETWEEN, // "left" | "center" | "right" | "space-between" style: SignatureStyle.BOX, // "line" | "box" | "clean" spacingBefore: "2.5cm", diff --git a/packages/markforge/README.md b/packages/markforge/README.md index a7b8b00..a2c7a4c 100644 --- a/packages/markforge/README.md +++ b/packages/markforge/README.md @@ -248,7 +248,7 @@ export default defineConfig({ }, // Document signatures and approval block - signatureBlock: { + signatures: { align: SignatureAlign.SPACE_BETWEEN, style: SignatureStyle.BOX, spacingBefore: "2.5cm", @@ -303,7 +303,7 @@ export default defineConfig({ | `numberHeadings` | `boolean \| object` | `false` | Automated hierarchical decimal numbering for headings (`1.`, `1.1.`, etc.) | | `coverPage` | `boolean \| object` | `false` | Isolated front cover page builder with presets and company logo | | `backCover` | `boolean \| object` | `false` | Isolated closing back cover page builder with corporate contact info | -| `signatureBlock` | `object` | `undefined` | Formal multi-signatory approval and signing block | +| `signatures` | `object` | `undefined` | Formal multi-signatory approval and signing block | | `math` | `boolean` | `false` | KaTeX LaTeX math equation rendering (`$inline$` and `$$block$$`) | | `security` | `object` | `undefined` | ISO 32000-2 AES-256 PDF encryption, user/owner passwords, and permissions | | `watermark` | `string \| object` | `undefined` | 100% unselectable diagonal watermark configuration | diff --git a/packages/markforge/package.json b/packages/markforge/package.json index 2aff904..dd3ddf9 100644 --- a/packages/markforge/package.json +++ b/packages/markforge/package.json @@ -1,6 +1,6 @@ { "name": "@masumdev/markforge", - "version": "0.4.0", + "version": "0.4.1", "description": "Modern, high-performance Markdown & MDX multi-format document publishing engine and CLI (DOCX, PDF, HTML, and Images) with embedded HTML/CSS, image inliner, and Ink terminal UI.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/markforge/src/cli.tsx b/packages/markforge/src/cli.tsx index 179fd2f..88d2b8a 100644 --- a/packages/markforge/src/cli.tsx +++ b/packages/markforge/src/cli.tsx @@ -79,7 +79,7 @@ async function main() { const { loadConfig } = await import("./config/loadConfig.js"); // Load config (auto-discover starting from input file's dir or explicit -c path) - const { config: fileConfig } = await loadConfig( + const { config: fileConfig, configPath } = await loadConfig( options.config as string | undefined, path.dirname(resolvedInputPath) ); @@ -107,11 +107,20 @@ async function main() { config: mergedConfig, }); + const themeLabel = + typeof mergedConfig.theme === "object" ? "Custom (ThemeProps)" : String(mergedConfig.theme || "corporate"); + console.log(`\n======================================================`); - console.log(` MarkForge Live Reload Preview Server`); + console.log(` MarkForge Live Reload Preview Server v${MARKFORGE_VERSION}`); + console.log(`======================================================`); console.log(` Target File : ${resolvedInputPath}`); + console.log(` Config File : ${configPath || "(default auto-discovery)"}`); + console.log(` Theme : ${themeLabel} | Syntax: ${mergedConfig.syntaxTheme || "github-dark"}`); + console.log(` Layout : ${mergedConfig.paperSize || "A4"} (${mergedConfig.orientation || "portrait"})`); console.log(` Server URL : ${instance.url}`); - console.log(` Author : Ma'sum (https://github.com/masumrpg)`); + console.log(` Auto-Open : ${Boolean(options.open) ? "Enabled" : "Disabled"}`); + console.log(` Author : Ma'sum (@masumrpg)`); + console.log(` GitHub : https://github.com/masumrpg`); console.log(`======================================================\n`); console.log(`[READY] Watching for markdown and style changes. Press Ctrl+C to exit.\n`); return; @@ -127,6 +136,7 @@ async function main() { { if (res.errors && res.errors.length > 0) { process.exitCode = 1; diff --git a/packages/markforge/src/config/resolveConfig.ts b/packages/markforge/src/config/resolveConfig.ts index ea9a024..3d19171 100644 --- a/packages/markforge/src/config/resolveConfig.ts +++ b/packages/markforge/src/config/resolveConfig.ts @@ -146,8 +146,18 @@ export interface NormalizedCoverPage { logo?: string; logoWidth?: number | string; bgGradient?: string; + backgroundColor?: string; textColor?: string; + titleColor?: string; + subtitleColor?: string; + accentColor?: string; footerText?: string; + address?: string; + email?: string; + phone?: string; + website?: string; + social?: Record; + copyright?: string; } export interface NormalizedBackCover { @@ -155,20 +165,28 @@ export interface NormalizedBackCover { preset: "modern" | "corporate" | "minimal" | "contact-card"; title: string; subtitle?: string; + author?: string; company?: string; + version?: string; + date?: string; address?: string; email?: string; phone?: string; website?: string; social?: Record; copyright?: string; + footerText?: string; badge?: string; badgeColor?: string; badgeTextColor?: string; logo?: string; logoWidth?: number | string; bgGradient?: string; + backgroundColor?: string; textColor?: string; + titleColor?: string; + subtitleColor?: string; + accentColor?: string; } export interface NormalizedNumberHeadings { @@ -225,44 +243,65 @@ export interface ResolvedDocumentConfig { } /** - * Replaces dynamic variables ({title}, {subtitle}, {author}, {version}, {date}, {company}) in text templates. + * Replaces dynamic variables ({title}, {subtitle}, {author}, {version}, {date}, {company}, {year}, and any custom metadata keys) in text templates. */ export function replaceDocumentTokens( template: string = "", - meta: { - title?: string; - subtitle?: string; - author?: string; - version?: string; - date?: string; - company?: string; - year?: string; - [key: string]: string | undefined; - } + meta: Record = {} ): string { - const currentYear = meta.year || new Date().getFullYear().toString(); - return template - .replace(/\{title\}/gi, meta.title || "") - .replace(/\{subtitle\}/gi, meta.subtitle || "") - .replace(/\{author\}/gi, meta.author || "") - .replace(/\{version\}/gi, meta.version || "") - .replace(/\{date\}/gi, meta.date || "") - .replace(/\{year\}/gi, currentYear) - .replace(/\{company\}/gi, meta.company || ""); + if (!template) return ""; + const currentYear = meta.year ? String(meta.year) : new Date().getFullYear().toString(); + + // Normalized lowercase token map + const tokenMap: Record = { + title: meta.title ? String(meta.title) : "", + subtitle: meta.subtitle ? String(meta.subtitle) : "", + author: meta.author ? (Array.isArray(meta.author) ? meta.author.join(", ") : String(meta.author)) : "", + version: meta.version ? String(meta.version) : "", + date: meta.date ? String(meta.date) : "", + company: meta.company ? String(meta.company) : "", + year: currentYear, + }; + + // Merge all custom metadata keys dynamically (including nested metadata object if present) + if (meta.metadata && typeof meta.metadata === "object") { + for (const [key, val] of Object.entries(meta.metadata as Record)) { + if (val !== undefined && val !== null) { + tokenMap[key.toLowerCase()] = String(val); + } + } + } + + for (const [key, val] of Object.entries(meta)) { + if (val !== undefined && val !== null && typeof val !== "object") { + tokenMap[key.toLowerCase()] = String(val); + } + } + + // Replace any {token} dynamically (case-insensitive) + return template.replace(/\{([a-zA-Z0-9_\-]+)\}/gi, (match, tokenKey) => { + const lowerKey = tokenKey.toLowerCase(); + if (lowerKey in tokenMap) { + return tokenMap[lowerKey]; + } + return match; + }); } /** - * Normalizes watermark configuration into a consistent structured object. + * Normalizes watermark configuration into a consistent structured object with dynamic token replacement. */ export function normalizeWatermark( - rawWatermark?: string | WatermarkOptions | false + rawWatermark?: string | WatermarkOptions | false, + tokens?: Record ): NormalizedWatermark | undefined { if (!rawWatermark) { return undefined; } if (typeof rawWatermark === "string") { - const text = rawWatermark.trim(); + let text = rawWatermark.trim(); + if (tokens) text = replaceDocumentTokens(text, tokens); if (!text) return undefined; return { text, @@ -276,8 +315,10 @@ export function normalizeWatermark( if (typeof rawWatermark === "object") { if (!rawWatermark.text || !rawWatermark.text.trim()) return undefined; + let text = rawWatermark.text.trim(); + if (tokens) text = replaceDocumentTokens(text, tokens); return { - text: rawWatermark.text.trim(), + text, color: rawWatermark.color || "#94a3b8", opacity: typeof rawWatermark.opacity === "number" ? rawWatermark.opacity : 0.08, fontSize: rawWatermark.fontSize || 54, @@ -463,14 +504,7 @@ export function normalizeSignatures( */ export function normalizeCoverPage( rawCover?: boolean | CoverPageConfig | Record, - tokenCtx: { - title?: string; - subtitle?: string; - author?: string; - version?: string; - date?: string; - company?: string; - } = {} + tokenCtx: Record = {} ): NormalizedCoverPage | undefined { if (!rawCover) return undefined; @@ -478,21 +512,21 @@ export function normalizeCoverPage( if (cfg.enabled === false) return undefined; const preset = (cfg.preset as NormalizedCoverPage["preset"]) || "modern"; - const title = cfg.title ? replaceDocumentTokens(String(cfg.title), tokenCtx) : tokenCtx.title || "Document Title"; - const subtitle = cfg.subtitle ? replaceDocumentTokens(String(cfg.subtitle), tokenCtx) : tokenCtx.subtitle; + const title = cfg.title ? replaceDocumentTokens(String(cfg.title), tokenCtx) : tokenCtx.title ? String(tokenCtx.title) : "Document Title"; + const subtitle = cfg.subtitle ? replaceDocumentTokens(String(cfg.subtitle), tokenCtx) : tokenCtx.subtitle ? String(tokenCtx.subtitle) : undefined; const author = Array.isArray(cfg.author) ? cfg.author.join(", ") - : cfg.author ? replaceDocumentTokens(String(cfg.author), tokenCtx) : tokenCtx.author; - const company = cfg.company ? replaceDocumentTokens(String(cfg.company), tokenCtx) : tokenCtx.company; - const version = cfg.version ? replaceDocumentTokens(String(cfg.version), tokenCtx) : tokenCtx.version; + : cfg.author ? replaceDocumentTokens(String(cfg.author), tokenCtx) : tokenCtx.author ? String(tokenCtx.author) : undefined; + const company = cfg.company ? replaceDocumentTokens(String(cfg.company), tokenCtx) : tokenCtx.company ? String(tokenCtx.company) : undefined; + const version = cfg.version ? replaceDocumentTokens(String(cfg.version), tokenCtx) : tokenCtx.version ? String(tokenCtx.version) : undefined; let dateStr: string | undefined; if (typeof cfg.date === "string") { dateStr = replaceDocumentTokens(cfg.date, tokenCtx); } else if (cfg.date === true) { - dateStr = tokenCtx.date || new Date().toISOString().split("T")[0]; + dateStr = tokenCtx.date ? String(tokenCtx.date) : new Date().toISOString().split("T")[0]; } else { - dateStr = tokenCtx.date; + dateStr = tokenCtx.date ? String(tokenCtx.date) : undefined; } const badge = cfg.badge ? replaceDocumentTokens(String(cfg.badge), tokenCtx) : undefined; @@ -500,9 +534,32 @@ export function normalizeCoverPage( const badgeTextColor = typeof cfg.badgeTextColor === "string" ? cfg.badgeTextColor : undefined; const logo = typeof cfg.logo === "string" ? cfg.logo : undefined; const logoWidth = cfg.logoWidth as number | string | undefined; - const bgGradient = typeof cfg.bgGradient === "string" ? cfg.bgGradient : undefined; + const bgGradient = typeof cfg.bgGradient === "string" ? cfg.bgGradient : typeof cfg.backgroundColor === "string" ? cfg.backgroundColor : undefined; + const backgroundColor = typeof cfg.backgroundColor === "string" ? cfg.backgroundColor : undefined; const textColor = typeof cfg.textColor === "string" ? cfg.textColor : undefined; + const titleColor = typeof cfg.titleColor === "string" ? cfg.titleColor : undefined; + const subtitleColor = typeof cfg.subtitleColor === "string" ? cfg.subtitleColor : undefined; + const accentColor = typeof cfg.accentColor === "string" ? cfg.accentColor : undefined; const footerText = cfg.footerText ? replaceDocumentTokens(String(cfg.footerText), tokenCtx) : undefined; + const address = cfg.address ? replaceDocumentTokens(String(cfg.address), tokenCtx) : undefined; + const email = cfg.email ? replaceDocumentTokens(String(cfg.email), tokenCtx) : undefined; + const phone = cfg.phone ? replaceDocumentTokens(String(cfg.phone), tokenCtx) : undefined; + const website = cfg.website ? replaceDocumentTokens(String(cfg.website), tokenCtx) : undefined; + + let socialMap: Record | undefined; + if (cfg.social && typeof cfg.social === "object") { + socialMap = {}; + for (const [k, v] of Object.entries(cfg.social)) { + if (typeof v === "string") { + socialMap[k] = replaceDocumentTokens(v, tokenCtx); + } + } + } + + const currentYear = new Date().getFullYear().toString(); + const copyright = cfg.copyright + ? replaceDocumentTokens(String(cfg.copyright), { ...tokenCtx, year: currentYear }) + : undefined; return { enabled: true, @@ -519,8 +576,18 @@ export function normalizeCoverPage( logo, logoWidth, bgGradient, + backgroundColor, textColor, + titleColor, + subtitleColor, + accentColor, footerText, + address, + email, + phone, + website, + social: socialMap, + copyright, }; } @@ -529,14 +596,7 @@ export function normalizeCoverPage( */ export function normalizeBackCover( rawBack?: boolean | BackCoverConfig | Record, - tokenCtx: { - title?: string; - subtitle?: string; - author?: string; - version?: string; - date?: string; - company?: string; - } = {} + tokenCtx: Record = {} ): NormalizedBackCover | undefined { if (!rawBack) return undefined; @@ -546,7 +606,12 @@ export function normalizeBackCover( const preset = (cfg.preset as NormalizedBackCover["preset"]) || "modern"; const title = cfg.title ? replaceDocumentTokens(String(cfg.title), tokenCtx) : "Thank You"; const subtitle = cfg.subtitle ? replaceDocumentTokens(String(cfg.subtitle), tokenCtx) : undefined; - const company = cfg.company ? replaceDocumentTokens(String(cfg.company), tokenCtx) : tokenCtx.company; + const author = Array.isArray(cfg.author) + ? cfg.author.join(", ") + : cfg.author ? replaceDocumentTokens(String(cfg.author), tokenCtx) : tokenCtx.author ? String(tokenCtx.author) : undefined; + const company = cfg.company ? replaceDocumentTokens(String(cfg.company), tokenCtx) : tokenCtx.company ? String(tokenCtx.company) : undefined; + const version = cfg.version ? replaceDocumentTokens(String(cfg.version), tokenCtx) : tokenCtx.version ? String(tokenCtx.version) : undefined; + const date = typeof cfg.date === "string" ? replaceDocumentTokens(cfg.date, tokenCtx) : tokenCtx.date ? String(tokenCtx.date) : undefined; const address = cfg.address ? replaceDocumentTokens(String(cfg.address), tokenCtx) : undefined; const email = cfg.email ? replaceDocumentTokens(String(cfg.email), tokenCtx) : undefined; const phone = cfg.phone ? replaceDocumentTokens(String(cfg.phone), tokenCtx) : undefined; @@ -569,33 +634,46 @@ export function normalizeBackCover( ? `Copyright (c) ${currentYear} ${company}. All Rights Reserved.` : undefined; + const footerText = cfg.footerText ? replaceDocumentTokens(String(cfg.footerText), tokenCtx) : undefined; const badge = cfg.badge ? replaceDocumentTokens(String(cfg.badge), tokenCtx) : undefined; const badgeColor = typeof cfg.badgeColor === "string" ? cfg.badgeColor : undefined; const badgeTextColor = typeof cfg.badgeTextColor === "string" ? cfg.badgeTextColor : undefined; const logo = typeof cfg.logo === "string" ? cfg.logo : undefined; const logoWidth = cfg.logoWidth as number | string | undefined; - const bgGradient = typeof cfg.bgGradient === "string" ? cfg.bgGradient : undefined; + const bgGradient = typeof cfg.bgGradient === "string" ? cfg.bgGradient : typeof cfg.backgroundColor === "string" ? cfg.backgroundColor : undefined; + const backgroundColor = typeof cfg.backgroundColor === "string" ? cfg.backgroundColor : undefined; const textColor = typeof cfg.textColor === "string" ? cfg.textColor : undefined; + const titleColor = typeof cfg.titleColor === "string" ? cfg.titleColor : undefined; + const subtitleColor = typeof cfg.subtitleColor === "string" ? cfg.subtitleColor : undefined; + const accentColor = typeof cfg.accentColor === "string" ? cfg.accentColor : undefined; return { enabled: true, preset, title, subtitle, + author, company, + version, + date, address, email, phone, website, social: socialMap, copyright, + footerText, badge, badgeColor, badgeTextColor, logo, logoWidth, bgGradient, + backgroundColor, textColor, + titleColor, + subtitleColor, + accentColor, }; } @@ -660,7 +738,15 @@ export function resolveDocumentConfig( const company = (mergedMeta.company as string) || undefined; const lang = (mergedMeta.lang as string) || "en"; - const tokenContext = { title, subtitle, author, version, date, company }; + const tokenContext: Record = { + ...mergedMeta, + title, + subtitle, + author, + version, + date, + company, + }; // 2. Visual Theme & Layout (Frontmatter > Config > Default) const theme = (mergedMeta.theme as MarkforgeTheme) || userConfig.theme || DEFAULT_CONFIG.theme; @@ -714,7 +800,7 @@ export function resolveDocumentConfig( ? userConfig.watermark : DEFAULT_CONFIG.watermark; - const watermark = normalizeWatermark(rawWatermark); + const watermark = normalizeWatermark(rawWatermark, tokenContext); // 5.5 Signatures and Approval Block const rawSignatures = (mergedMeta.signatures as SignatureBlockConfig | SignatureItem[]) || diff --git a/packages/markforge/src/config/types.ts b/packages/markforge/src/config/types.ts index e71b9c4..4fd29ef 100644 --- a/packages/markforge/src/config/types.ts +++ b/packages/markforge/src/config/types.ts @@ -260,11 +260,25 @@ export interface CoverPageConfig { * Custom background color or CSS gradient for the cover page. */ bgGradient?: string; + backgroundColor?: string; /** - * Custom text color for cover typography. + * Custom typography colors. */ textColor?: string; + titleColor?: string; + subtitleColor?: string; + accentColor?: string; + + /** + * Contact details (for covers that render organization cards). + */ + address?: string; + email?: string; + phone?: string; + website?: string; + social?: BackCoverSocial; + copyright?: string; /** * Custom footer notes at the bottom of the cover page. @@ -310,11 +324,26 @@ export interface BackCoverConfig { */ subtitle?: string; + /** + * Author / signatory shown on the closing page. + */ + author?: string | string[]; + /** * Organization or company name (falls back to document company). */ company?: string; + /** + * Document version shown on the back cover. + */ + version?: string; + + /** + * Date shown on the back cover. + */ + date?: string | boolean; + /** * Office address or headquarters location. */ @@ -345,6 +374,11 @@ export interface BackCoverConfig { */ copyright?: string; + /** + * Custom footer text at the bottom. + */ + footerText?: string; + /** * Brand logo path, URL, or Base64 data URI. */ @@ -370,11 +404,15 @@ export interface BackCoverConfig { * Custom CSS background gradient or solid hex color for the back cover. */ bgGradient?: string; + backgroundColor?: string; /** - * Custom text color for back cover typography. + * Custom typography colors. */ textColor?: string; + titleColor?: string; + subtitleColor?: string; + accentColor?: string; } export interface NumberHeadingsOptions { diff --git a/packages/markforge/src/core/docx/docxBuilder.ts b/packages/markforge/src/core/docx/docxBuilder.ts index e8cca03..82bc6f8 100644 --- a/packages/markforge/src/core/docx/docxBuilder.ts +++ b/packages/markforge/src/core/docx/docxBuilder.ts @@ -20,9 +20,11 @@ import { ShadingType, ExternalHyperlink, TabStopType, + PageBreak, + NumberFormat, } from "docx"; import type { ParsedMarkdownDocument, MarkdownInlineSpan } from "../parser.js"; -import { parseInlineSpans } from "../parser.js"; +import { parseInlineSpans, applyHeadingNumbering } from "../parser.js"; import type { MarkforgeConfig, ThemeProps } from "../../config/types.js"; import { resolveImage } from "../imageResolver.js"; import { tokenizeCodeLine } from "../syntax/syntaxHighlighter.js"; @@ -352,6 +354,11 @@ export async function buildDocxDocument( } } + // 1.4 Apply heading numbering if enabled + if (resolved.numberHeadings?.enabled !== false && resolved.numberHeadings) { + applyHeadingNumbering(doc.nodes, doc.tocEntries, resolved.numberHeadings); + } + // 1.5 Table of Contents (TOC) Card if enabled if (resolved.toc) { const headingNodes = doc.nodes.filter( @@ -426,7 +433,12 @@ export async function buildDocxDocument( }); docElements.push(tocCard); - docElements.push(new Paragraph({ spacing: { after: 200 } })); + // Hard page break after Table of Contents so main content starts on fresh page + docElements.push( + new Paragraph({ + children: [new PageBreak()], + }) + ); } } @@ -438,7 +450,7 @@ export async function buildDocxDocument( const runs = await convertInlinesToTextRuns(node.inlines, baseDir, { font: defaultFont, size: 34, // 17pt - color: textHex, + color: primaryDarkHex, bold: true, }); @@ -484,7 +496,7 @@ export async function buildDocxDocument( const runs = await convertInlinesToTextRuns(node.inlines, baseDir, { font: defaultFont, size: 22, // 11pt - color: textHex, + color: primaryDarkHex, bold: true, }); @@ -1332,6 +1344,10 @@ export async function buildDocxDocument( docSections.push({ properties: { page: { + pageNumbers: { + start: 1, + formatType: NumberFormat.DECIMAL, + }, size: { width: resolved.paperDimensions.widthTwip, height: resolved.paperDimensions.heightTwip, diff --git a/packages/markforge/src/core/html/htmlBuilder.ts b/packages/markforge/src/core/html/htmlBuilder.ts index cda9999..e9d40e0 100644 --- a/packages/markforge/src/core/html/htmlBuilder.ts +++ b/packages/markforge/src/core/html/htmlBuilder.ts @@ -1,11 +1,17 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import type { ParsedMarkdownDocument, MarkdownInlineSpan, MarkdownASTNode } from "../parser.js"; +import { type ParsedMarkdownDocument, type MarkdownInlineSpan, type MarkdownASTNode, applyHeadingNumbering } from "../parser.js"; import type { MarkforgeConfig } from "../../config/types.js"; import { resolveImage } from "../imageResolver.js"; import { generateThemeCss, THEME_COMPONENTS } from "./htmlThemes.js"; import { highlightCodeToHtml } from "../syntax/syntaxHighlighter.js"; -import { resolveDocumentConfig, type NormalizedCoverPage, type NormalizedBackCover, type ResolvedDocumentConfig } from "../../config/resolveConfig.js"; +import { + resolveDocumentConfig, + replaceDocumentTokens, + type NormalizedCoverPage, + type NormalizedBackCover, + type ResolvedDocumentConfig, +} from "../../config/resolveConfig.js"; import { renderMathToHtml, KATEX_INLINE_CSS } from "../math/mathRenderer.js"; /** @@ -21,11 +27,12 @@ export function escapeHtml(str: string): string { } /** - * Converts Markdown inline spans to HTML markup. + * Renders inline Markdown spans (bold, italic, links, images, footnotes, math, etc.) to HTML with token replacement. */ export async function renderInlinesToHtml( spans: MarkdownInlineSpan[] = [], - baseDir: string = process.cwd() + baseDir: string = process.cwd(), + tokens?: Record ): Promise { let result = ""; @@ -43,26 +50,26 @@ export async function renderInlinesToHtml( } if (span.type === "link" && span.url) { - const inner = span.children ? await renderInlinesToHtml(span.children, baseDir) : escapeHtml(span.content); + const inner = span.children ? await renderInlinesToHtml(span.children, baseDir, tokens) : escapeHtml(tokens ? replaceDocumentTokens(span.content, tokens) : span.content); const title = span.title ? ` title="${escapeHtml(span.title)}"` : ""; result += `${inner}`; continue; } if (span.type === "bold") { - const inner = span.children ? await renderInlinesToHtml(span.children, baseDir) : escapeHtml(span.content); + const inner = span.children ? await renderInlinesToHtml(span.children, baseDir, tokens) : escapeHtml(tokens ? replaceDocumentTokens(span.content, tokens) : span.content); result += `${inner}`; continue; } if (span.type === "italic") { - const inner = span.children ? await renderInlinesToHtml(span.children, baseDir) : escapeHtml(span.content); + const inner = span.children ? await renderInlinesToHtml(span.children, baseDir, tokens) : escapeHtml(tokens ? replaceDocumentTokens(span.content, tokens) : span.content); result += `${inner}`; continue; } if (span.type === "strikethrough") { - const inner = span.children ? await renderInlinesToHtml(span.children, baseDir) : escapeHtml(span.content); + const inner = span.children ? await renderInlinesToHtml(span.children, baseDir, tokens) : escapeHtml(tokens ? replaceDocumentTokens(span.content, tokens) : span.content); result += `${inner}`; continue; } @@ -88,7 +95,8 @@ export async function renderInlinesToHtml( continue; } - result += escapeHtml(span.content); + const content = tokens ? replaceDocumentTokens(span.content, tokens) : span.content; + result += escapeHtml(content); } return result; @@ -100,19 +108,21 @@ export async function renderInlinesToHtml( export async function renderNodesToHtml( nodes: MarkdownASTNode[], resolved: ResolvedDocumentConfig, - baseDir: string = process.cwd() + baseDir: string = process.cwd(), + tokens?: Record ): Promise { let bodyHtml = ""; + const tokenCtx = tokens || (resolved as unknown as Record); for (const node of nodes) { if (node.type === "heading") { - const inner = await renderInlinesToHtml(node.inlines, baseDir); + const inner = await renderInlinesToHtml(node.inlines, baseDir, tokenCtx); bodyHtml += ` ${inner}\n`; continue; } if (node.type === "paragraph") { - const inner = await renderInlinesToHtml(node.inlines, baseDir); + const inner = await renderInlinesToHtml(node.inlines, baseDir, tokenCtx); bodyHtml += `

${inner}

\n`; continue; } @@ -128,7 +138,7 @@ export async function renderNodesToHtml( let colChildrenHtml = ""; for (const col of node.children || []) { - const colInner = await renderNodesToHtml(col.children || [], resolved, baseDir); + const colInner = await renderNodesToHtml(col.children || [], resolved, baseDir, tokenCtx); colChildrenHtml += `
\n${colInner}
\n`; } @@ -150,7 +160,7 @@ export async function renderNodesToHtml( } if (node.type === "callout") { - const inner = await renderInlinesToHtml(node.inlines, baseDir); + const inner = await renderInlinesToHtml(node.inlines, baseDir, tokenCtx); const CALLOUT_STYLES: Record = { NOTE: { bg: "#ECFDFD", border: "#33CDCF", titleColor: "#009DA0" }, TIP: { bg: "#ecfdf5", border: "#10b981", titleColor: "#10b981" }, @@ -167,7 +177,7 @@ export async function renderNodesToHtml( } if (node.type === "blockquote") { - const inner = await renderInlinesToHtml(node.inlines, baseDir); + const inner = await renderInlinesToHtml(node.inlines, baseDir, tokenCtx); bodyHtml += `
${inner}
\n`; continue; } @@ -179,7 +189,7 @@ export async function renderNodesToHtml( for (const cell of row.children || []) { const tag = cell.isHeader ? "th" : "td"; const align = cell.align ? ` align="${cell.align}"` : ""; - const inner = await renderInlinesToHtml(cell.inlines, baseDir); + const inner = await renderInlinesToHtml(cell.inlines, baseDir, tokenCtx); bodyHtml += ` <${tag}${align}>${inner}\n`; } bodyHtml += ` \n`; @@ -192,7 +202,7 @@ export async function renderNodesToHtml( const tag = node.ordered ? "ol" : "ul"; bodyHtml += ` <${tag}>\n`; for (const item of node.children) { - const inner = await renderInlinesToHtml(item.inlines, baseDir); + const inner = await renderInlinesToHtml(item.inlines, baseDir, tokenCtx); bodyHtml += `
  • ${inner}
  • \n`; } bodyHtml += ` \n`; @@ -644,6 +654,11 @@ export async function buildHtmlDocument( bodyHtml += ` \n`; } + // 1.5 Apply heading numbering if configured + if (resolved.numberHeadings?.enabled !== false && resolved.numberHeadings) { + applyHeadingNumbering(doc.nodes, doc.tocEntries, resolved.numberHeadings); + } + // 2. Table of Contents if enabled if (resolved.toc && doc.tocEntries.length > 0) { bodyHtml += ` \n`; } - // 3. Render AST Nodes - bodyHtml += await renderNodesToHtml(doc.nodes, resolved, baseDir); + // 3. Render AST Nodes with dynamic token interpolation + const mergedTokens: Record = { + ...config.metadata, + ...doc.metadata, + ...(resolved as unknown as Record), + title: resolved.title, + subtitle: resolved.subtitle, + author: resolved.author, + version: resolved.version, + date: resolved.date, + company: resolved.company, + }; + const nodesHtml = await renderNodesToHtml(doc.nodes, resolved, baseDir, mergedTokens); + bodyHtml += `
    \n${nodesHtml}
    \n`; // 3.5 Footnotes Section let footnotesHtml = ""; if (doc.footnoteDefs && doc.footnoteDefs.length > 0) { let fnListHtml = ""; for (const def of doc.footnoteDefs) { - const defInner = await renderInlinesToHtml(def.inlines, baseDir); + const defInner = await renderInlinesToHtml(def.inlines, baseDir, mergedTokens); fnListHtml += `
  • ${defInner} ↩
  • \n`; } footnotesHtml = `\n
    \n
    \n
      \n${fnListHtml}
    \n
    \n`; @@ -696,17 +723,7 @@ export async function buildHtmlDocument( } @media print { .document-watermark { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - width: 100vw; - height: 100vh; - pointer-events: none; - z-index: 0; - -webkit-print-color-adjust: exact; - print-color-adjust: exact; + display: none !important; } } `; diff --git a/packages/markforge/src/core/html/htmlThemes.ts b/packages/markforge/src/core/html/htmlThemes.ts index f33d48f..1ddaa38 100644 --- a/packages/markforge/src/core/html/htmlThemes.ts +++ b/packages/markforge/src/core/html/htmlThemes.ts @@ -31,7 +31,7 @@ export const THEME_COMPONENTS = ` .document-meta { font-size: 0.9rem; color: var(--mf-text-muted); display: flex; gap: 1.5rem; flex-wrap: wrap; } /* Table of Contents */ -.table-of-contents { background: var(--mf-card-bg); border: 1px solid var(--mf-border); border-radius: 8px; padding: 1.5rem 2rem; margin: 2rem 0; } +.table-of-contents { background: var(--mf-card-bg); border: 1px solid var(--mf-border); border-radius: 8px; padding: 1.5rem 2rem; margin: 2rem 0; page-break-after: always; break-after: page; } .table-of-contents h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mf-text-muted); margin: 0 0 1rem 0; } .table-of-contents ul { list-style: none; padding: 0; margin: 0; } .table-of-contents li { padding: 0.25rem 0; } @@ -108,11 +108,13 @@ export const THEME_CORPORATE = ` } body { background-color: var(--mf-bg); color: var(--mf-text); font-family: var(--mf-font-family); font-size: 15px; line-height: 1.65; margin: 0; padding: 2.5rem; } .document-container { max-width: 860px; margin: 0 auto; position: relative; z-index: 1; } -h1, h2, h3, h4, h5, h6 { color: var(--mf-text); font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.8rem; line-height: 1.25; } -h1 { font-size: 2.2rem; border-bottom: 2px solid var(--mf-primary); padding-bottom: 0.5rem; } +h1, h2, h3, h4, h5, h6 { color: var(--mf-primary-dark); font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.8rem; line-height: 1.25; } +h1 { font-size: 2.2rem; color: var(--mf-primary-dark); border-bottom: 2.5px solid var(--mf-primary); padding-bottom: 0.5rem; } h2 { font-size: 1.6rem; color: var(--mf-primary-dark); border-bottom: 1px solid #CCFBF1; padding-bottom: 0.4rem; } -h3 { font-size: 1.3rem; } -h4 { font-size: 1.1rem; } +h3 { font-size: 1.3rem; color: var(--mf-primary-dark); } +h4 { font-size: 1.1rem; color: var(--mf-primary-dark); } +h5 { font-size: 1.0rem; color: var(--mf-primary-dark); } +h6 { font-size: 0.9rem; color: var(--mf-primary-dark); } p { margin: 0.8rem 0; } `; @@ -166,11 +168,13 @@ export function generateThemeCss(theme?: MarkforgeTheme): string { } body { background-color: var(--mf-bg); color: var(--mf-text); font-family: var(--mf-font-family); font-size: 15px; line-height: 1.65; margin: 0; padding: 2.5rem; } .document-container { max-width: 860px; margin: 0 auto; position: relative; z-index: 1; } -h1, h2, h3, h4, h5, h6 { color: var(--mf-text); font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.8rem; line-height: 1.25; } -h1 { font-size: 2.2rem; border-bottom: 2px solid var(--mf-primary); padding-bottom: 0.5rem; } +h1, h2, h3, h4, h5, h6 { color: var(--mf-primary-dark); font-weight: 700; margin-top: 1.8rem; margin-bottom: 0.8rem; line-height: 1.25; } +h1 { font-size: 2.2rem; color: var(--mf-primary-dark); border-bottom: 2.5px solid var(--mf-primary); padding-bottom: 0.5rem; } h2 { font-size: 1.6rem; color: var(--mf-primary-dark); border-bottom: 1px solid var(--mf-border); padding-bottom: 0.4rem; } -h3 { font-size: 1.3rem; } -h4 { font-size: 1.1rem; } +h3 { font-size: 1.3rem; color: var(--mf-primary-dark); } +h4 { font-size: 1.1rem; color: var(--mf-primary-dark); } +h5 { font-size: 1.0rem; color: var(--mf-primary-dark); } +h6 { font-size: 0.9rem; color: var(--mf-primary-dark); } p { margin: 0.8rem 0; } ${theme.customCss || ""} `; diff --git a/packages/markforge/src/core/parser.ts b/packages/markforge/src/core/parser.ts index e5c18fd..5cdd2db 100644 --- a/packages/markforge/src/core/parser.ts +++ b/packages/markforge/src/core/parser.ts @@ -281,7 +281,18 @@ export function parseInlineSpans(text: string): MarkdownInlineSpan[] { } } - return spans; + // Merge adjacent plain text spans + const merged: MarkdownInlineSpan[] = []; + for (const s of spans) { + const prev = merged[merged.length - 1]; + if (prev && prev.type === "text" && s.type === "text") { + prev.content += s.content; + } else { + merged.push(s); + } + } + + return merged; } /** @@ -296,9 +307,9 @@ export function slugify(text: string): string { } /** - * Applies hierarchical numbering to headings (e.g. "1.", "1.1", "1.1.1"). + * Applies hierarchical numbering to headings (e.g. "1.", "1.1.", "1.1.1."). */ -function applyHeadingNumbering( +export function applyHeadingNumbering( nodes: MarkdownASTNode[], tocEntries: { id: string; text: string; level: number }[], options: NumberHeadingsOptions @@ -311,6 +322,8 @@ function applyHeadingNumbering( for (const node of nodes) { if (node.type === "heading" && node.level) { + if ((node as { _numbered?: boolean })._numbered) continue; + const lvl = node.level; if (lvl > depth) continue; if (lvl === 1 && skipH1) continue; @@ -331,6 +344,7 @@ function applyHeadingNumbering( const originalText = node.text || ""; node.text = fullPrefix + originalText; node.inlines = parseInlineSpans(node.text); + (node as { _numbered?: boolean })._numbered = true; // Update matching TOC entry const toc = tocEntries.find((t) => t.id === node.id); diff --git a/packages/markforge/src/core/pdf/pdfBuilder.ts b/packages/markforge/src/core/pdf/pdfBuilder.ts index 3964cf5..26989b7 100644 --- a/packages/markforge/src/core/pdf/pdfBuilder.ts +++ b/packages/markforge/src/core/pdf/pdfBuilder.ts @@ -284,6 +284,37 @@ export function injectPagedMediaStyles( min-height: 100vh; height: 100vh; box-sizing: border-box; + break-before: page; + break-after: avoid; + }` + : ""; + + const tocPageCss = resolved.toc + ? ` + @page toc-page { + size: ${size} ${orientation}; + margin-top: ${top}; + margin-bottom: ${bottom}; + margin-left: ${left}; + margin-right: ${right}; + ${buildZoneCss("top-left", resolved.header?.left)} + ${buildZoneCss("top-center", resolved.header?.center)} + ${buildZoneCss("top-right", resolved.header?.right)} + ${buildZoneCss("bottom-left", resolved.footer?.left)} + ${buildZoneCss("bottom-center", resolved.footer?.center)} + @bottom-right { + content: counter(page, lower-roman); + font-size: 9pt; + color: #94a3b8; + } + } + .table-of-contents { + page: toc-page; + page-break-after: always; + break-after: page; + } + .markforge-content-body { + counter-reset: page 1; }` : ""; @@ -302,6 +333,7 @@ export function injectPagedMediaStyles( ${buildZoneCss("bottom-right", resolved.footer?.right, true)} } ${coverPageCss} + ${tocPageCss} ${backCoverCss} @media print { body { padding: 0; } @@ -449,6 +481,8 @@ export async function buildPdfDocument( pdfDoc.setProducer("MarkForge (by Ma'sum)"); pdfDoc.setModificationDate(new Date()); + + // When backCover is enabled, Chromium headless print generates a trailing overflow page after the 100vh back cover section. // Remove this trailing blank page so the back cover is the true final page. if (resolved.backCover?.enabled && pdfDoc.getPageCount() > 2) { @@ -459,9 +493,10 @@ export async function buildPdfDocument( const wmPng = generateWatermarkPngBuffer(chromePath, resolved.watermark); if (wmPng) { const embeddedPng = await pdfDoc.embedPng(wmPng); + const totalPages = pdfDoc.getPageCount(); const pages = pdfDoc.getPages(); const startPageIndex = resolved.coverPage?.enabled ? 1 : 0; - const endPageIndex = resolved.backCover?.enabled ? pages.length - 1 : pages.length; + const endPageIndex = resolved.backCover?.enabled ? totalPages - 1 : totalPages; for (let i = startPageIndex; i < endPageIndex; i++) { const page = pages[i]; diff --git a/packages/markforge/src/index.ts b/packages/markforge/src/index.ts index f469295..118c7ed 100644 --- a/packages/markforge/src/index.ts +++ b/packages/markforge/src/index.ts @@ -1,5 +1,5 @@ export { compileMarkdown, compileMarkdown as markforge, formatServerTimestamp } from "./core/engine.js"; -export { parseMarkdownDocument, parseMarkdown, parseInlineSpans, slugify } from "./core/parser.js"; +export { parseMarkdownDocument, parseMarkdown, parseInlineSpans, slugify, applyHeadingNumbering } from "./core/parser.js"; export type { MarkdownNodeType, MarkdownInlineSpan, diff --git a/packages/markforge/src/ui/App.tsx b/packages/markforge/src/ui/App.tsx index bf06a2e..faf693a 100644 --- a/packages/markforge/src/ui/App.tsx +++ b/packages/markforge/src/ui/App.tsx @@ -9,10 +9,11 @@ import { compileMarkdown } from "../core/engine.js"; export interface AppProps { inputFile: string; config: MarkforgeConfig; + configPath?: string | null; onComplete?: (result: CompilationResult) => void; } -export const App: React.FC = ({ inputFile, config, onComplete }) => { +export const App: React.FC = ({ inputFile, config, configPath, onComplete }) => { const [status, setStatus] = useState("Initializing..."); const [isCompiling, setIsCompiling] = useState(true); const [result, setResult] = useState(null); @@ -51,6 +52,8 @@ export const App: React.FC = ({ inputFile, config, onComplete }) => {
    diff --git a/packages/markforge/src/ui/Header.tsx b/packages/markforge/src/ui/Header.tsx index 91011f5..3eddf84 100644 --- a/packages/markforge/src/ui/Header.tsx +++ b/packages/markforge/src/ui/Header.tsx @@ -2,15 +2,17 @@ import React from "react"; import * as path from "node:path"; import { Box, Text } from "ink"; import { MARKFORGE_VERSION } from "../version.js"; -import type { MarkforgeTheme } from "../config/types.js"; +import type { MarkforgeConfig, MarkforgeTheme } from "../config/types.js"; export interface HeaderProps { inputFile?: string; + configPath?: string | null; + config?: MarkforgeConfig; theme?: MarkforgeTheme; targetFormats?: string[]; } -function formatDisplayPath(p?: string): string { +function formatDisplayPath(p?: string | null): string { if (!p) return ""; try { const rel = path.relative(process.cwd(), p); @@ -22,11 +24,57 @@ function formatDisplayPath(p?: string): string { export const Header: React.FC = ({ inputFile, + configPath, + config, theme = "corporate", targetFormats = ["docx", "pdf"], }) => { + const resolvedTheme = config?.theme || theme; const themeLabel = - typeof theme === "object" ? "Custom (ThemeProps)" : String(theme || "corporate"); + typeof resolvedTheme === "object" ? "Custom (ThemeProps)" : String(resolvedTheme || "corporate"); + + // Collect enabled active features + const activeFeatures: string[] = []; + if (config?.toc) activeFeatures.push("TOC"); + if (config?.numberHeadings) { + if (typeof config.numberHeadings === "object") { + const isNumEnabled = config.numberHeadings.enabled ?? true; + if (isNumEnabled) { + activeFeatures.push(`Numbering (depth ${config.numberHeadings.depth ?? 3})`); + } + } else if (config.numberHeadings === true) { + activeFeatures.push("Numbering (depth 3)"); + } + } + if (config?.coverPage) { + if (typeof config.coverPage === "object" && config.coverPage.enabled !== false) { + activeFeatures.push(`Cover (${config.coverPage.preset || "corporate"})`); + } else if (config.coverPage === true) { + activeFeatures.push("Cover (corporate)"); + } + } + if (config?.backCover) { + if (typeof config.backCover === "object" && config.backCover.enabled !== false) { + activeFeatures.push(`BackCover (${config.backCover.preset || "corporate"})`); + } else if (config.backCover === true) { + activeFeatures.push("BackCover (corporate)"); + } + } + if (config?.watermark) { + const wmText = typeof config.watermark === "object" ? config.watermark.text : config.watermark; + if (wmText) { + activeFeatures.push(`Watermark ("${wmText}")`); + } + } + if (config?.security && (config.security.userPassword || config.security.ownerPassword)) { + activeFeatures.push("AES-256 Security"); + } + if (config?.math !== false) activeFeatures.push("KaTeX Math"); + if (config?.syntaxTheme) activeFeatures.push(`Syntax (${config.syntaxTheme})`); + + const paperLabel = `${config?.paperSize || "A4"} (${config?.orientation || "portrait"})`; + const outputLabel = config?.outputDir ? formatDisplayPath(config.outputDir) : "./ (default)"; + const configLabel = configPath ? formatDisplayPath(configPath) : "(default auto-discovery)"; return ( @@ -54,15 +102,23 @@ export const Header: React.FC = ({ {/* Source & Configuration Info */} - {inputFile && ( + {/* Row 1: Source & Config */} + Source: {formatDisplayPath(inputFile)} - )} + + Config: + + {configLabel} + + + + {/* Row 2: Theme & Targets */} Theme: @@ -79,6 +135,32 @@ export const Header: React.FC = ({ )} + + {/* Row 3: Paper Layout & Output Directory */} + + + Layout: + + {paperLabel} + + + + Output: + + {outputLabel} + + + + + {/* Row 4: Active Features List */} + {activeFeatures.length > 0 && ( + + Modules: + + {activeFeatures.join(" | ")} + + + )} {/* Author & GitHub Footer in Header Box */} @@ -101,3 +183,4 @@ export const Header: React.FC = ({ ); }; + diff --git a/packages/markforge/src/version.ts b/packages/markforge/src/version.ts index 3b91d71..96360c1 100644 --- a/packages/markforge/src/version.ts +++ b/packages/markforge/src/version.ts @@ -25,16 +25,14 @@ try { // ignore } -/** Hardcoded fallback — only used if package.json cannot be found at runtime. */ -const FALLBACK_VERSION = "0.4.0"; - /** * Walks up the directory tree from `fromDir` to find the nearest * `package.json` belonging to `@masumdev/markforge` and returns its version. + * Throws an explicit error if package.json cannot be found or is missing version. */ function readVersionFromPackageJson(fromDir: string): string { let currentDir = fromDir; - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 10; i++) { try { const pkgJsonPath = path.join(currentDir, "package.json"); if (fs.existsSync(pkgJsonPath)) { @@ -53,7 +51,9 @@ function readVersionFromPackageJson(fromDir: string): string { if (parentDir === currentDir) break; currentDir = parentDir; } - return FALLBACK_VERSION; + throw new Error( + "Failed to resolve '@masumdev/markforge' package version: package.json was not found or is missing a valid 'version' field." + ); } function getPackageDir(): string { @@ -68,8 +68,7 @@ function getPackageDir(): string { } /** - * Dynamically resolved version — reads from the nearest package.json at runtime. - * Falls back to the hardcoded FALLBACK_VERSION constant if not found. + * Dynamically resolved version — reads strictly from the nearest package.json at runtime. */ export const MARKFORGE_VERSION: string = readVersionFromPackageJson(getPackageDir()); diff --git a/packages/markforge/test/enterpriseFeatures.test.ts b/packages/markforge/test/enterpriseFeatures.test.ts index 28338bb..d43a798 100644 --- a/packages/markforge/test/enterpriseFeatures.test.ts +++ b/packages/markforge/test/enterpriseFeatures.test.ts @@ -14,6 +14,7 @@ import { normalizeBackCover, normalizeNumberHeadings, normalizeSecurity, + type MarkdownASTNode, } from "../src/index.js"; describe("MarkForge Enterprise Features", () => { @@ -65,7 +66,7 @@ Document contents after cover page. const md = `# Physics Formula\n\nThe mass-energy equivalence is $E = mc^2$.\n\n$$\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n`; const doc = parseMarkdown(md); - expect(doc.nodes.some((n) => n.type === "mathBlock")).toBe(true); + expect(doc.nodes.some((n: MarkdownASTNode) => n.type === "mathBlock")).toBe(true); const html = await buildHtmlDocument(doc, {}); expect(html).toContain("katex"); @@ -86,7 +87,7 @@ This is the right column content. ::: `; const doc = parseMarkdown(md); - const colNode = doc.nodes.find((n) => n.type === "columns"); + const colNode = doc.nodes.find((n: MarkdownASTNode) => n.type === "columns"); expect(colNode).toBeDefined(); expect(colNode?.columnsCount).toBe(2); expect(colNode?.children?.length).toBe(2); @@ -165,7 +166,7 @@ numberHeadings: ## Approach `; const doc = parseMarkdown(md); - const headings = doc.nodes.filter((n) => n.type === "heading"); + const headings = doc.nodes.filter((n: MarkdownASTNode) => n.type === "heading"); expect(headings[0].text).toBe("1. Introduction"); expect(headings[1].text).toBe("1.1. Background"); @@ -304,4 +305,55 @@ Body paragraph before closing page. expect(resolved.version).toBe("1.0.0"); expect(resolved.lang).toBe("en"); }); + + it("Feature 9: Arbitrary Metadata Record Token Interpolation & Watermark Tokens", async () => { + const md = `--- +title: "System Design" +--- +# Overview +This document references {metakuda} and {custom_key}. +`; + const doc = parseMarkdown(md); + const config = { + watermark: "CONFIDENTIAL {METAKUDA}", + metadata: { + metakuda: "Kuda", + custom_key: "CustomValue123", + }, + }; + + const resolved = resolveDocumentConfig(doc.metadata as Record, config); + expect(resolved.watermark?.text).toBe("CONFIDENTIAL Kuda"); + + const html = await buildHtmlDocument(doc, config); + expect(html).toContain("CONFIDENTIAL KUDA"); + expect(html).toContain("This document references Kuda and CustomValue123."); + }); + + it("Feature 10: numberHeadings Config Propagation & TOC Page Break", async () => { + const md = `# First Section +## Subsection +### Detail Level +# Second Section +`; + const doc = parseMarkdown(md); + const config = { + toc: true, + numberHeadings: { + enabled: true, + depth: 3, + skipH1: false, + }, + }; + + const html = await buildHtmlDocument(doc, config); + expect(html).toContain("1. First Section"); + expect(html).toContain("1.1. Subsection"); + expect(html).toContain("1.1.1. Detail Level"); + expect(html).toContain("2. Second Section"); + expect(html).toContain("page-break-after: always; break-after: page;"); + + const docxBuf = await buildDocxDocument(doc, config); + expect(docxBuf.length).toBeGreaterThan(1000); + }); });