diff --git a/CHANGELOG.md b/CHANGELOG.md index 335be4e..cb10555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.7.0] - 2026-03-14 + ### Added - Unit tests for `blocks.lua`, `academic.lua`, `reader_inlines.lua`, `reader_blocks.lua`, `reader_academic.lua` (235 new assertions, 505 total across 11 test files) - Golden baseline comparison (`make test-golden`) in CI pipeline - `make test-all` target for single-command full validation (lint + test + golden + reader + validate) - Wildcard test discovery in `make test-unit` (auto-discovers new test files) +- Unit tests for task list checkboxes, subfigure extraction, LineBlock handler, inline edge cases (Quoted, SmallCaps, Span anchors), and reader block handler output (130 new assertions, 621 total) +- Reader round-trip golden baselines (`make test-reader-golden`, `make update-reader-golden`) +- Shared `THEOREM_VARIANTS` and `THEOREM_REF_PREFIXES` constants in `lib/utils.lua` +- SECURITY.md with supported version policy ### Changed +- **Content version** bumped from `0.7.0` to `0.7.1` to align with cdx-core v0.7.1 - Aligned `make lint` flags with CI (`--no-unused-args --no-max-line-length`) - Updated CONTRIBUTING.md pre-PR command to `make test-all` +- Consolidated theorem variant and reference prefix definitions from `academic.lua`/`inlines.lua` into shared `utils.lua` constants +- Removed dead code: unused `caption` parameter from `blocks.image()`, unused `convert_inline`/`_handlers` exports from `inlines.lua`, `has_class` re-export from `inlines.lua` ### Fixed - README Pandoc version requirement: corrected from 2.11+ to 3.0+ +- Table cell complex content: now collects all child nodes from nested blocks, not just text nodes +- Measurement sentinel: defaults to `value=0`/`unit=""` with stderr warnings for unparseable input +- RawBlock format preservation: sets `language` field from block format (e.g., `html`, `latex`) +- Citation prefix/suffix: prefix applied only to first citation, suffix/locator only to last (multi-citation fix) ## [0.6.0] - 2026-02-17 diff --git a/Makefile b/Makefile index d1be426..c457312 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ TEST_INPUTS := $(wildcard tests/inputs/*.md) TEST_OUTPUTS := $(patsubst tests/inputs/%.md,tests/outputs/%.json,$(TEST_INPUTS)) TEST_CDX := $(patsubst tests/inputs/%.md,tests/outputs/%.cdx,$(TEST_INPUTS)) -.PHONY: all test clean test-json test-cdx test-reader test-unit test-golden test-all help check-deps validate-schema lint +.PHONY: all test clean test-json test-cdx test-reader test-unit test-golden test-reader-golden test-all help check-deps validate-schema lint all: test @@ -139,6 +139,31 @@ update-golden: test-json done @echo "Golden baselines updated." +# Compare reader round-trip outputs against golden baselines +# Note: Pandoc Span/Div attribute ordering is non-deterministic between process +# invocations, so this target is NOT included in test-all. Use update-reader-golden +# to capture baselines, then run test-reader-golden in the same make invocation: +# make update-reader-golden && make test-reader-golden +test-reader-golden: test-reader + @echo "Comparing reader round-trip against golden outputs..." + @fail=0; for f in tests/outputs/*.roundtrip.md; do \ + base=$$(basename $$f); \ + if [ -f tests/expected/$$base ]; then \ + diff -q $$f tests/expected/$$base > /dev/null 2>&1 || { echo "DIFF: $$base"; diff tests/expected/$$base $$f | head -20; fail=1; }; \ + fi; \ + done; \ + [ $$fail -eq 0 ] && echo "All reader golden tests passed." || { echo "Reader golden test failures detected."; exit 1; } + +# Regenerate reader round-trip golden baselines +update-reader-golden: test-reader + @echo "Updating reader round-trip golden baselines..." + @mkdir -p tests/expected + @for f in tests/outputs/*.roundtrip.md; do \ + base=$$(basename $$f); \ + cp $$f tests/expected/$$base; \ + done + @echo "Reader golden baselines updated." + # Run all tests (unit + integration + golden + reader + lint + validate) test-all: lint test test-golden test-reader validate diff --git a/README.md b/README.md index d0deceb..6ac53eb 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ The writer produces a JSON structure with three sections: } }, "content": { - "version": "0.7.0", + "version": "0.7.1", "blocks": [...] }, "dublin_core": { @@ -380,6 +380,10 @@ The reader converts Codex back to standard Pandoc elements. Most block types sur - [codex-file-format-spec](https://github.com/Entrolution/codex-file-format-spec) - Format specification - [cdx-core](https://github.com/Entrolution/cdx-core) - Rust library and CLI +## Security + +See [SECURITY.md](SECURITY.md) for the supported version policy and how to report vulnerabilities. + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting PRs. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0becc02 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 0.7.x | :white_check_mark: | +| < 0.7.0 | :x: | + +Only the latest patch release of the current minor version receives security updates. + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly: + +1. **Do not** open a public issue. +2. Email the maintainers or use [GitHub Security Advisories](https://github.com/Entrolution/cdx-pandoc/security/advisories/new) to report privately. +3. Include a description of the vulnerability, steps to reproduce, and any potential impact. + +We will acknowledge receipt within 48 hours and aim to provide a fix or mitigation plan within 7 days. + +## Scope + +cdx-pandoc is a document conversion tool that processes untrusted input (Markdown, LaTeX, Word, etc.) via Pandoc. Security considerations include: + +- **Input handling**: The Lua writer processes Pandoc AST structures. Malformed input is handled by Pandoc's parser before reaching this code. +- **Shell script**: `scripts/pandoc-to-cdx.sh` invokes external tools (`pandoc`, `jq`, `sha256sum`, `zip`). File paths are quoted to prevent injection. +- **No network access**: The writer and reader operate entirely offline with no network calls. +- **No code execution**: The writer produces static JSON output. No user-supplied code is evaluated. diff --git a/codex.lua b/codex.lua index a611eb6..f0f1adf 100644 --- a/codex.lua +++ b/codex.lua @@ -53,7 +53,7 @@ blocks.set_academic(academic) -- Spec version local CODEX_VERSION = "0.1" -local CONTENT_VERSION = "0.7.0" +local CONTENT_VERSION = "0.7.1" -- Generate ISO 8601 timestamp local function iso_timestamp() diff --git a/lib/academic.lua b/lib/academic.lua index 2d1af69..fac63cd 100644 --- a/lib/academic.lua +++ b/lib/academic.lua @@ -23,19 +23,21 @@ function M.set_extension_tracker(tracker) track_extension = tracker or function() end end --- Theorem variant set -local theorem_variants = { - theorem = true, lemma = true, proposition = true, corollary = true, - definition = true, conjecture = true, remark = true, example = true -} +-- Theorem variant set (from shared constants) +local theorem_variants = utils.THEOREM_VARIANTS -- Academic class set (all classes handled by this module) -local academic_classes = { - theorem = true, lemma = true, proposition = true, corollary = true, - definition = true, conjecture = true, remark = true, example = true, - proof = true, exercise = true, ["exercise-set"] = true, - algorithm = true, abstract = true, ["equation-group"] = true -} +-- Derived from theorem variants plus non-theorem academic types +local academic_classes = {} +for k in pairs(theorem_variants) do + academic_classes[k] = true +end +academic_classes.proof = true +academic_classes.exercise = true +academic_classes["exercise-set"] = true +academic_classes.algorithm = true +academic_classes.abstract = true +academic_classes["equation-group"] = true -- Classify a Div as an academic block type or nil -- @param classes Array of CSS classes @@ -363,12 +365,20 @@ function M.equation_group(text) return nil end + -- Split on \\ (double backslash) line breaks using plain string find + -- Cannot use gmatch character class here: [^\\\\]+ splits on every single + -- backslash, which would fragment LaTeX commands like \frac, \alpha, etc. local lines = {} - for line in inner:gmatch("[^\\\\]+") do - local trimmed = line:match("^%s*(.-)%s*$") + local pos = 1 + while pos <= #inner do + local s, e = inner:find("\\\\", pos, true) + local segment = s and inner:sub(pos, s - 1) or inner:sub(pos) + local trimmed = segment:match("^%s*(.-)%s*$") if trimmed and trimmed ~= "" then table.insert(lines, trimmed) end + if not s then break end + pos = e + 1 end return { diff --git a/lib/blocks.lua b/lib/blocks.lua index e2118ec..6892e22 100644 --- a/lib/blocks.lua +++ b/lib/blocks.lua @@ -83,12 +83,16 @@ block_handlers.DefinitionList = function(block) return M.definition_list(block) block_handlers.Figure = function(block) return M.figure(block) end block_handlers.RawBlock = function(block) - return { + local result = { type = "codeBlock", children = { {type = "text", value = block.text} } } + if block.format and block.format ~= "" then + result.language = block.format + end + return result end block_handlers.LineBlock = function(block) @@ -517,13 +521,11 @@ function M.table_cell(cell) if #blocks > 0 and blocks[1].type == "paragraph" then children = blocks[1].children else - -- Complex cell content - just use first text we find + -- Complex cell content - collect all children from converted blocks for _, b in ipairs(blocks) do if b.children then for _, c in ipairs(b.children) do - if c.type == "text" then - table.insert(children, c) - end + table.insert(children, c) end end end @@ -807,7 +809,7 @@ function M.extract_subfigure(div, attrs, div_id) end -- Convert Image inline to image block (with optional dimensions) -function M.image(img, caption) +function M.image(img) local src = img.src or img.target or "" local alt = "" @@ -840,14 +842,6 @@ function M.image(img, caption) end end - -- Caption passed from outside (legacy path) - if caption and caption.long then - local cap_text = pandoc.utils.stringify(caption.long) - if cap_text and cap_text ~= "" then - result.title = cap_text - end - end - return result end diff --git a/lib/inlines.lua b/lib/inlines.lua index fb22a1f..b8bfdf9 100644 --- a/lib/inlines.lua +++ b/lib/inlines.lua @@ -4,6 +4,7 @@ -- Load shared utilities local utils = dofile((PANDOC_SCRIPT_FILE and (PANDOC_SCRIPT_FILE:match("(.*/)" ) or "") or "") .. "lib/utils.lua") local deep_copy = utils.deep_copy +local has_class = utils.has_class local M = {} @@ -37,9 +38,6 @@ function M.reset_context() M._default_context = M.new_context() end --- Check if a class list contains a specific class (delegates to utils) -M.has_class = utils.has_class - -- Check if two marks are equal local function marks_equal(m1, m2) if type(m1) ~= type(m2) then @@ -199,11 +197,8 @@ inline_handlers.Code = function(inline, marks, ctx) return {M.text_node(inline.text, new_marks)} end --- Academic cross-reference prefix patterns -local theorem_ref_prefixes = { - ["thm-"] = true, ["lem-"] = true, ["prop-"] = true, ["cor-"] = true, - ["def-"] = true, ["conj-"] = true, ["rem-"] = true, ["ex-"] = true -} +-- Academic cross-reference prefix patterns (from shared constants) +local theorem_ref_prefixes = utils.THEOREM_REF_PREFIXES local function detect_academic_ref(target) if not target or not target:match("^#") then @@ -358,7 +353,7 @@ inline_handlers.Span = function(inline, marks, ctx) local has_semantic_mark = false -- Entity class - if M.has_class(classes, "entity") then + if has_class(classes, "entity") then track_extension(utils.EXT_SEMANTIC) local entity_mark = {type = "semantic:entity"} if attributes.uri then @@ -377,7 +372,7 @@ inline_handlers.Span = function(inline, marks, ctx) end -- Glossary class - if M.has_class(classes, "glossary") then + if has_class(classes, "glossary") then track_extension(utils.EXT_SEMANTIC) local glossary_mark = {type = "semantic:glossary"} if attributes.ref then @@ -391,10 +386,18 @@ inline_handlers.Span = function(inline, marks, ctx) end -- Measurement class - if M.has_class(classes, "measurement") then + if has_class(classes, "measurement") then local text = pandoc.utils.stringify(inline.content) local value = tonumber(attributes.value) or tonumber(text:match("([%d%.]+)")) local unit = attributes.unit or text:match("%d+%.?%d*%s*(%a+)") + if not value then + io.stderr:write("Warning: measurement missing parseable value: " .. text .. "\n") + value = 0 + end + if not unit then + io.stderr:write("Warning: measurement missing parseable unit: " .. text .. "\n") + unit = "" + end return {{ type = "measurement_sentinel", value = value, @@ -452,12 +455,6 @@ convert_inline = function(inline, marks, ctx) return {} end --- Export convert_inline as module function -M.convert_inline = convert_inline - --- Expose handlers table for testing -M._handlers = inline_handlers - -- Check if a node is a sentinel (non-text node that needs block-level handling) local function is_sentinel(node) return node.type and node.type:match("_sentinel$") diff --git a/lib/metadata.lua b/lib/metadata.lua index 0e97e98..76ae485 100644 --- a/lib/metadata.lua +++ b/lib/metadata.lua @@ -109,7 +109,8 @@ local function extract_authors(meta) local t = author.t or author.tag - if t == "MetaList" or (type(author) == "table" and #author > 0) then + -- MetaList is always a list of authors + if t == "MetaList" then local authors = {} for _, a in ipairs(author) do local author_obj = extract_single_author(a) @@ -120,11 +121,29 @@ local function extract_authors(meta) if #authors > 0 then return authors end - else - -- Single author - local author_obj = extract_single_author(author) - if author_obj then - return { author_obj } + return nil + end + + -- MetaInlines, MetaString, or Pandoc 3.x Inlines → single author + -- Try extracting as a single author first; this handles both tagged + -- MetaInlines/MetaString and Pandoc 3.x Inlines (which have no tag + -- but are stringify-able) + local single = extract_single_author(author) + if single then + return { single } + end + + -- Fallback: untagged table that isn't stringify-able → try as list + if type(author) == "table" and not t and #author > 0 then + local authors = {} + for _, a in ipairs(author) do + local author_obj = extract_single_author(a) + if author_obj then + table.insert(authors, author_obj) + end + end + if #authors > 0 then + return authors end end diff --git a/lib/reader_blocks.lua b/lib/reader_blocks.lua index 0f113da..7956239 100644 --- a/lib/reader_blocks.lua +++ b/lib/reader_blocks.lua @@ -188,7 +188,8 @@ function M.convert_list_item(item) for _, child in ipairs(item.children or {}) do local converted = M.convert_block(child) if converted then - if type(converted) == "table" and converted.tag then + -- Pandoc elements are userdata with t/tag accessors, not plain tables + if converted.t or converted.tag then table.insert(blocks, converted) elseif type(converted) == "table" and #converted > 0 then for _, b in ipairs(converted) do diff --git a/lib/reader_inlines.lua b/lib/reader_inlines.lua index 1d20ec1..4ea71c3 100644 --- a/lib/reader_inlines.lua +++ b/lib/reader_inlines.lua @@ -117,32 +117,34 @@ function M.convert_node(node) if has_citation then local refs = has_citation.refs or {} local citations = {} - for _, ref in ipairs(refs) do + for i, ref in ipairs(refs) do -- Create citation with id and mode local mode = "NormalCitation" if has_citation.suppressAuthor then mode = "SuppressAuthor" end local citation = pandoc.Citation(ref, mode) - -- Set prefix if present - if has_citation.prefix then + -- Set prefix on first citation only + if i == 1 and has_citation.prefix then citation.prefix = {pandoc.Str(has_citation.prefix)} end - -- Set suffix from locator and/or suffix - local suffix_text = "" - if has_citation.locator then - suffix_text = "p. " .. has_citation.locator - end - if has_citation.suffix then + -- Set suffix/locator on last citation only + if i == #refs then + local suffix_text = "" + if has_citation.locator then + suffix_text = "p. " .. has_citation.locator + end + if has_citation.suffix then + if suffix_text ~= "" then + suffix_text = suffix_text .. " " .. has_citation.suffix + else + suffix_text = has_citation.suffix + end + end if suffix_text ~= "" then - suffix_text = suffix_text .. " " .. has_citation.suffix - else - suffix_text = has_citation.suffix + citation.suffix = {pandoc.Str(suffix_text)} end end - if suffix_text ~= "" then - citation.suffix = {pandoc.Str(suffix_text)} - end table.insert(citations, citation) end -- Create Cite element with the text content diff --git a/lib/utils.lua b/lib/utils.lua index 9710bbc..5310154 100644 --- a/lib/utils.lua +++ b/lib/utils.lua @@ -1,5 +1,8 @@ -- lib/utils.lua -- Shared utility functions for Codex Pandoc writer +-- NOTE: Each module that requires utils.lua uses dofile() rather than require() +-- because Pandoc custom writers don't support Lua's package.path/require system. +-- This means utils.lua is re-executed per module, but the overhead is negligible. local M = {} @@ -44,6 +47,18 @@ end M.EXT_SEMANTIC = "codex.semantic" M.EXT_ACADEMIC = "codex.academic" +-- Theorem variant types (shared between writer and reader) +M.THEOREM_VARIANTS = { + theorem = true, lemma = true, proposition = true, corollary = true, + definition = true, conjecture = true, remark = true, example = true +} + +-- Theorem cross-reference prefix patterns (shared between writer and reader) +M.THEOREM_REF_PREFIXES = { + ["thm-"] = true, ["lem-"] = true, ["prop-"] = true, ["cor-"] = true, + ["def-"] = true, ["conj-"] = true, ["rem-"] = true, ["ex-"] = true +} + -- Check if a class list contains a specific class -- @param classes Array of CSS class strings -- @param class_name Class to search for diff --git a/scripts/pandoc-to-cdx.sh b/scripts/pandoc-to-cdx.sh index 8b92e81..12e9a07 100755 --- a/scripts/pandoc-to-cdx.sh +++ b/scripts/pandoc-to-cdx.sh @@ -129,6 +129,11 @@ convert() { # Extract dublin_core section jq '.dublin_core' "$json_output" > "$temp_dir/cdx/metadata/dublin-core.json" + # Extract JSON-LD metadata if present + if jq -e '.jsonld' "$json_output" > /dev/null 2>&1; then + jq '.jsonld' "$json_output" > "$temp_dir/cdx/metadata/jsonld.json" + fi + # Compute content hash info "Computing content hash..." local content_hash diff --git a/tests/expected/abstract.json b/tests/expected/abstract.json index ea66ad5..34daca8 100644 --- a/tests/expected/abstract.json +++ b/tests/expected/abstract.json @@ -41,11 +41,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Abstract Test", "type": "Text" @@ -57,7 +62,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Abstract Test" }, diff --git a/tests/expected/abstract.roundtrip.md b/tests/expected/abstract.roundtrip.md new file mode 100644 index 0000000..335c75e --- /dev/null +++ b/tests/expected/abstract.roundtrip.md @@ -0,0 +1,11 @@ +:::: abstract +This paper presents a novel approach to document formatting. It demonstrates semantic block types for academic content. + +::: keywords +machine learning, document formats, semantic web +::: +:::: + +# Introduction + +The rest of the document follows here. diff --git a/tests/expected/academic-crossrefs.json b/tests/expected/academic-crossrefs.json index 380c743..d66beef 100644 --- a/tests/expected/academic-crossrefs.json +++ b/tests/expected/academic-crossrefs.json @@ -225,11 +225,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Academic Cross-References Test", "type": "Text" @@ -241,7 +246,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Academic Cross-References Test" }, diff --git a/tests/expected/academic-crossrefs.roundtrip.md b/tests/expected/academic-crossrefs.roundtrip.md new file mode 100644 index 0000000..48f4825 --- /dev/null +++ b/tests/expected/academic-crossrefs.roundtrip.md @@ -0,0 +1,15 @@ +# Cross-References + +By [Theorem 1](#thm-max), the maximum is on the boundary. + +See [Lemma 2](#lem-bound) and [Definition 3](#def-cont) for details. + +The result follows from [Proposition 4](#prop-unique). + +From [Equation (1)](#eq-euler), we derive the identity. + +As shown in [Algorithm 1](#alg-sort), the complexity is $O(n \log n)$. + +This is a [regular link](https://example.com) that should not be converted. + +Also see [Corollary 5](#cor-max) and [Conjecture 6](#conj-prime). diff --git a/tests/expected/academic-full.json b/tests/expected/academic-full.json index 2128d69..4e68d77 100644 --- a/tests/expected/academic-full.json +++ b/tests/expected/academic-full.json @@ -433,22 +433,9 @@ { "environment": "align", "lines": [ - "nabla", - "cdot", - "mathbf{E} &=", - "frac{", - "rho}{", - "epsilon_0}", - "nabla", - "cdot", - "mathbf{B} &= 0", - "nabla", - "times", - "mathbf{E} &= -", - "frac{", - "partial", - "mathbf{B}}{", - "partial t}" + "\\nabla \\cdot \\mathbf{E} &= \\frac{\\rho}{\\epsilon_0}", + "\\nabla \\cdot \\mathbf{B} &= 0", + "\\nabla \\times \\mathbf{E} &= -\\frac{\\partial \\mathbf{B}}{\\partial t}" ], "type": "academic:equation-group", "value": "\\begin{align}\n\\nabla \\cdot \\mathbf{E} &= \\frac{\\rho}{\\epsilon_0} \\\\\n\\nabla \\cdot \\mathbf{B} &= 0 \\\\\n\\nabla \\times \\mathbf{E} &= -\\frac{\\partial \\mathbf{B}}{\\partial t}\n\\end{align}" @@ -535,11 +522,16 @@ "variant": "tip" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "date": "2024-01-15", "format": "application/vnd.codex+json", "title": "Academic Kitchen Sink", @@ -552,7 +544,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "datePublished": "2024-01-15", "name": "Academic Kitchen Sink" diff --git a/tests/expected/academic-full.roundtrip.md b/tests/expected/academic-full.roundtrip.md new file mode 100644 index 0000000..f97124e --- /dev/null +++ b/tests/expected/academic-full.roundtrip.md @@ -0,0 +1,85 @@ +:::: abstract +This document tests all academic block types interacting together, including theorems, proofs, exercises, algorithms, and cross-references. + +::: keywords +mathematics, testing, academic blocks +::: +:::: + +# Theorems and Proofs + +::: {#thm-fund .theorem title="Fundamental Theorem"} +Every continuous function on a closed interval $[a,b]$ attains its maximum. +::: + +::: {.proof of="thm-fund" method="contradiction"} +Suppose $f$ does not attain its maximum. Then for every $x \in [a,b]$, there exists $y$ with $f(y) > f(x)$. This contradicts compactness. +::: + +::: {#lem-helper .lemma} +If $f$ is continuous on $[a,b]$, then $f$ is bounded. +::: + +::: {#def-compact .definition title="Compactness"} +A set $K$ is compact if every open cover has a finite subcover. +::: + +By [Theorem 1](#thm-fund) and [Lemma 1](#lem-helper), the result follows. + +# Exercises + +::::: {#ex-calc .exercise difficulty="easy"} +Compute the derivative of $f(x) = x^3 - 2x + 1$. + +::: hint +Use the power rule: $\frac{d}{dx}x^n = nx^{n-1}$. +::: + +::: {.solution visibility="hidden"} +$f'(x) = 3x^2 - 2$ +::: +::::: + +# Algorithms + +::: {#alg-binary .algorithm title="Binary Search"} +``` algorithm +function BinarySearch(A, target, lo, hi) + if lo > hi then + return NOT_FOUND + end if + mid ← (lo + hi) / 2 + if A[mid] = target then + return mid + else if A[mid] < target then + return BinarySearch(A, target, mid+1, hi) + else + return BinarySearch(A, target, lo, mid-1) + end if +end function +``` +::: + +See [Algorithm 1](#alg-binary) for the implementation. + +# Equation Groups + +$$\begin{align} +\nabla \cdot \mathbf{E} &= \frac{\rho}{\epsilon_0} \\ +\nabla \cdot \mathbf{B} &= 0 \\ +\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} +\end{align}$$ + +See [Equation (1)](#eq-maxwell) for Maxwell's equations. + +# Admonitions + +::: warning +### Convergence + +The series may not converge for all values of $x$. +::: + +::: tip +Use the ratio test to check convergence. +::: diff --git a/tests/expected/admonitions.json b/tests/expected/admonitions.json index e15da73..cfee8be 100644 --- a/tests/expected/admonitions.json +++ b/tests/expected/admonitions.json @@ -116,11 +116,16 @@ "variant": "caution" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Admonitions Test", "type": "Text" @@ -132,7 +137,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Admonitions Test" }, diff --git a/tests/expected/admonitions.roundtrip.md b/tests/expected/admonitions.roundtrip.md new file mode 100644 index 0000000..25b1ed6 --- /dev/null +++ b/tests/expected/admonitions.roundtrip.md @@ -0,0 +1,29 @@ +# Admonition Examples + +::: note +This is a simple note. +::: + +::: warning +### Be Careful + +This operation is **destructive** and cannot be undone. +::: + +::: tip +### Pro Tip + +Use keyboard shortcuts to speed up your workflow. +::: + +::: danger +Do not run this in production. +::: + +::: important +Read the documentation before proceeding. +::: + +::: caution +Check your input values carefully. +::: diff --git a/tests/expected/algorithm.json b/tests/expected/algorithm.json index f6f4338..dc9335f 100644 --- a/tests/expected/algorithm.json +++ b/tests/expected/algorithm.json @@ -27,11 +27,16 @@ "type": "academic:algorithm" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Algorithm Test", "type": "Text" @@ -43,7 +48,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Algorithm Test" }, diff --git a/tests/expected/algorithm.roundtrip.md b/tests/expected/algorithm.roundtrip.md new file mode 100644 index 0000000..463410c --- /dev/null +++ b/tests/expected/algorithm.roundtrip.md @@ -0,0 +1,13 @@ +# Algorithms + +::: {#alg-sort .algorithm title="QuickSort"} +``` algorithm +function QuickSort(A, lo, hi) + if lo < hi then + p ← Partition(A, lo, hi) + QuickSort(A, lo, p-1) + QuickSort(A, p+1, hi) + end if +end function +``` +::: diff --git a/tests/expected/basic.json b/tests/expected/basic.json index 9f3b61d..9ae4bfa 100644 --- a/tests/expected/basic.json +++ b/tests/expected/basic.json @@ -307,11 +307,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "date": "2025-01-28", "description": "A simple test document for the Codex Pandoc writer.", "format": "application/vnd.codex+json", @@ -331,7 +336,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "datePublished": "2025-01-28", "description": "A simple test document for the Codex Pandoc writer.", diff --git a/tests/expected/basic.roundtrip.md b/tests/expected/basic.roundtrip.md new file mode 100644 index 0000000..7c338ea --- /dev/null +++ b/tests/expected/basic.roundtrip.md @@ -0,0 +1,42 @@ +# Introduction + +This is a **basic** test document with *various* formatting options. + +## Text Formatting + +Here's some `inline code` and a [link to example](https://example.com "Example Link"). + +Text can be ~~strikethrough~~ as well. + +## Lists + +Unordered list: + +- First item + +- Second item with **bold** + +- Third item with *italic* + +Ordered list: + +1. First numbered item + +2. Second numbered item + +3. Third numbered item + +## Code Block + +``` python +def hello_world(): + print("Hello, Codex!") +``` + +## Blockquote + +> This is a blockquote. It can span multiple lines. + +------------------------------------------------------------------------ + +The end. diff --git a/tests/expected/bibliography-csl.json b/tests/expected/bibliography-csl.json index b5b2902..a8f99bb 100644 --- a/tests/expected/bibliography-csl.json +++ b/tests/expected/bibliography-csl.json @@ -99,11 +99,16 @@ "type": "heading" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Bibliography CSL Test", "type": "Text" @@ -115,7 +120,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Bibliography CSL Test" }, diff --git a/tests/expected/bibliography-csl.roundtrip.md b/tests/expected/bibliography-csl.roundtrip.md new file mode 100644 index 0000000..3d75332 --- /dev/null +++ b/tests/expected/bibliography-csl.roundtrip.md @@ -0,0 +1,9 @@ +# Introduction + +Recent advances in document processing have transformed how we create and share academic content [@smith2024]. + +As documented in [@jones2023], modern publishing requires careful attention to metadata standards. + +For institutional perspectives, see [@teamproject2022] for comprehensive analysis. + +# References diff --git a/tests/expected/citations.json b/tests/expected/citations.json index d853f2e..7edc73f 100644 --- a/tests/expected/citations.json +++ b/tests/expected/citations.json @@ -91,11 +91,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Citation Test", "type": "Text" @@ -107,7 +112,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Citation Test" }, diff --git a/tests/expected/citations.roundtrip.md b/tests/expected/citations.roundtrip.md new file mode 100644 index 0000000..b8bd0e8 --- /dev/null +++ b/tests/expected/citations.roundtrip.md @@ -0,0 +1,7 @@ +# Introduction + +As shown by [@smith2023], the results are significant. + +This has been confirmed [-@jones2024, pp. 42-45]. + +Multiple citations [@smith2023; @jones2024] support this. diff --git a/tests/expected/crossrefs.json b/tests/expected/crossrefs.json index 1fc7f1c..9d7e6f5 100644 --- a/tests/expected/crossrefs.json +++ b/tests/expected/crossrefs.json @@ -147,11 +147,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Cross-Reference Test", "type": "Text" @@ -163,7 +168,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Cross-Reference Test" }, diff --git a/tests/expected/crossrefs.roundtrip.md b/tests/expected/crossrefs.roundtrip.md new file mode 100644 index 0000000..1a61a77 --- /dev/null +++ b/tests/expected/crossrefs.roundtrip.md @@ -0,0 +1,17 @@ +# Introduction {#intro} + +This document tests internal cross-references. + +## Method + +See the [introduction](#intro) for context. + +For results, see [Section 3](#results). + +## Results + +The results build on [the method](#method) above. + +### Sub-section {#results-detail} + +More details available in [results detail](#results-detail). diff --git a/tests/expected/definition-list.json b/tests/expected/definition-list.json index f2aa392..851f5fa 100644 --- a/tests/expected/definition-list.json +++ b/tests/expected/definition-list.json @@ -134,11 +134,16 @@ "type": "definitionList" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Definition List Test", "type": "Text" @@ -150,7 +155,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Definition List Test" }, diff --git a/tests/expected/definition-list.roundtrip.md b/tests/expected/definition-list.roundtrip.md new file mode 100644 index 0000000..0f23f47 --- /dev/null +++ b/tests/expected/definition-list.roundtrip.md @@ -0,0 +1,15 @@ +# Definitions + +Term One + +: The first definition with **bold** text. + +Term Two + +: The second definition. + +: An alternate definition for term two. + +*Formatted* Term + +: A term with inline formatting in the term itself. diff --git a/tests/expected/empty.json b/tests/expected/empty.json index ddffa56..96fc8e1 100644 --- a/tests/expected/empty.json +++ b/tests/expected/empty.json @@ -1,7 +1,7 @@ { "content": { "blocks": [], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { diff --git a/tests/expected/empty.roundtrip.md b/tests/expected/empty.roundtrip.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/expected/empty.roundtrip.md @@ -0,0 +1 @@ + diff --git a/tests/expected/entities.json b/tests/expected/entities.json index 7ab13b8..9eb8ce4 100644 --- a/tests/expected/entities.json +++ b/tests/expected/entities.json @@ -146,11 +146,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Entity Linking Test", "type": "Text" @@ -162,7 +167,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Entity Linking Test" }, diff --git a/tests/expected/entities.roundtrip.md b/tests/expected/entities.roundtrip.md new file mode 100644 index 0000000..efce0a9 --- /dev/null +++ b/tests/expected/entities.roundtrip.md @@ -0,0 +1,26 @@ +# Famous Scientists + +This document tests entity linking with knowledge base references. + +[Albert Einstein]{.entity entityType="Person" +uri="https://www.wikidata.org/wiki/Q937"} developed the theory of relativity. + +[Marie Curie]{.entity source="Wikidata" entityType="Person" +uri="https://www.wikidata.org/wiki/Q7186"} pioneered research on radioactivity. + +The [Large Hadron Collider]{.entity entityType="Place" +uri="https://www.wikidata.org/wiki/Q83492"} is the world's largest particle accelerator. + +## Measurements + +The experiment measured + +[42.5 kg]{.measurement unit="kg" value="42.5"} + + of material. + +Temperature reached + +[100 °C]{.measurement unit="°C" value="100.0"} + +. diff --git a/tests/expected/equation-group.json b/tests/expected/equation-group.json index ad8fb8a..c52e643 100644 --- a/tests/expected/equation-group.json +++ b/tests/expected/equation-group.json @@ -59,11 +59,16 @@ "value": "E = mc^2" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Equation Group Test", "type": "Text" @@ -75,7 +80,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Equation Group Test" }, diff --git a/tests/expected/equation-group.roundtrip.md b/tests/expected/equation-group.roundtrip.md new file mode 100644 index 0000000..b3d45ff --- /dev/null +++ b/tests/expected/equation-group.roundtrip.md @@ -0,0 +1,17 @@ +# Aligned Equations + +$$\begin{align} +a &= b + c \\ +d &= e + f +\end{align}$$ + +# Gathered Equations + +$$\begin{gather} +x = y + z \\ +w = u + v +\end{gather}$$ + +# Regular Display Math + +$$E = mc^2$$ diff --git a/tests/expected/exercise.json b/tests/expected/exercise.json index 1bb373e..84db18f 100644 --- a/tests/expected/exercise.json +++ b/tests/expected/exercise.json @@ -149,11 +149,16 @@ "type": "academic:exercise-set" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Exercise Test", "type": "Text" @@ -165,7 +170,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Exercise Test" }, diff --git a/tests/expected/exercise.roundtrip.md b/tests/expected/exercise.roundtrip.md new file mode 100644 index 0000000..28b810d --- /dev/null +++ b/tests/expected/exercise.roundtrip.md @@ -0,0 +1,23 @@ +# Exercises + +::::: {#ex-1 .exercise difficulty="medium"} +Find the derivative of $f(x) = x^2 + 1$. + +::: hint +Use the power rule. +::: + +::: {.solution visibility="hidden"} +$f'(x) = 2x$ +::: +::::: + +::::: {#exercises-ch2 .exercise-set title="Chapter 2 Exercises"} +::: {#ex-2-1 .exercise difficulty="easy"} +Compute $\int x \, dx$. +::: + +::: {#ex-2-2 .exercise difficulty="hard"} +Compute $\int \frac{1}{1+x^4} \, dx$. +::: +::::: diff --git a/tests/expected/figures.json b/tests/expected/figures.json index b3e6fdf..8295ac3 100644 --- a/tests/expected/figures.json +++ b/tests/expected/figures.json @@ -109,11 +109,16 @@ "type": "figure" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Figures Test", "type": "Text" @@ -125,7 +130,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Figures Test" }, diff --git a/tests/expected/figures.roundtrip.md b/tests/expected/figures.roundtrip.md new file mode 100644 index 0000000..8ccb249 --- /dev/null +++ b/tests/expected/figures.roundtrip.md @@ -0,0 +1,13 @@ +# Figures + +## Simple Figure + +![A simple diagram](diagram.png "Diagram Title"){#fig-diagram} + +## Figure with Caption + +![This is the caption text](chart.png){#fig-chart} + +## Figure with Dimensions + +![Alt text](photo.png){#fig-photo} diff --git a/tests/expected/footnotes.json b/tests/expected/footnotes.json index 7df1ef9..e4c7c79 100644 --- a/tests/expected/footnotes.json +++ b/tests/expected/footnotes.json @@ -94,11 +94,16 @@ "type": "semantic:footnote" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Footnote Test", "type": "Text" @@ -110,7 +115,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Footnote Test" }, diff --git a/tests/expected/footnotes.roundtrip.md b/tests/expected/footnotes.roundtrip.md new file mode 100644 index 0000000..335c0df --- /dev/null +++ b/tests/expected/footnotes.roundtrip.md @@ -0,0 +1,9 @@ +# Introduction + +This is a statement with a footnote[^1]. + +Another reference[^2]. + +[^1]: This is the first footnote content. + +[^2]: This is the second footnote with **formatting**. diff --git a/tests/expected/glossary.json b/tests/expected/glossary.json index e8c773b..b8024ee 100644 --- a/tests/expected/glossary.json +++ b/tests/expected/glossary.json @@ -105,11 +105,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Glossary Test", "type": "Text" @@ -121,7 +126,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Glossary Test" }, diff --git a/tests/expected/glossary.roundtrip.md b/tests/expected/glossary.roundtrip.md new file mode 100644 index 0000000..6f659fa --- /dev/null +++ b/tests/expected/glossary.roundtrip.md @@ -0,0 +1,24 @@ +# Introduction + +This document tests glossary term definitions. + +## Definitions + +CRDT +: Conflict-free Replicated Data Type - a data structure that can be replicated across multiple computers. See also: eventual consistency. + + + +Eventual Consistency +: A consistency model used in distributed systems. See also: crdt. + + + +API +: Application Programming Interface - a set of protocols and tools for building software. + +## Usage + +In this document, we discuss [CRDT]{.glossary +ref="term-crdt"} technologies and their relationship to [eventual consistency]{.glossary +ref="term-eventual-consistency"}. diff --git a/tests/expected/images.json b/tests/expected/images.json index d936bd5..31193a5 100644 --- a/tests/expected/images.json +++ b/tests/expected/images.json @@ -58,11 +58,16 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Image Test", "type": "Text" @@ -74,7 +79,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Image Test" }, diff --git a/tests/expected/images.roundtrip.md b/tests/expected/images.roundtrip.md new file mode 100644 index 0000000..7c61efb --- /dev/null +++ b/tests/expected/images.roundtrip.md @@ -0,0 +1,9 @@ +# Images + +![Alt text](photo.png "Caption text") + +An inline image: + +![](icon.svg){alt="icon"} + +. diff --git a/tests/expected/math.json b/tests/expected/math.json index df35fd7..f8095ec 100644 --- a/tests/expected/math.json +++ b/tests/expected/math.json @@ -186,11 +186,16 @@ "type": "blockquote" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Math Test", "type": "Text" @@ -202,7 +207,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Math Test" }, diff --git a/tests/expected/math.roundtrip.md b/tests/expected/math.roundtrip.md new file mode 100644 index 0000000..1b0abee --- /dev/null +++ b/tests/expected/math.roundtrip.md @@ -0,0 +1,15 @@ +# Math Examples + +The equation $E = mc^2$ is famous. + +$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$ + +Mixed: start $a + b$ middle $c + d$ end. + +$x = 1$ starts a paragraph. + +A paragraph ends with $y = 2$. + +$\alpha$$\beta$ consecutive math (no text between). + +> Blockquote with $inline$ math. diff --git a/tests/expected/nested.json b/tests/expected/nested.json index 44d7b64..391c0d8 100644 --- a/tests/expected/nested.json +++ b/tests/expected/nested.json @@ -280,11 +280,16 @@ "type": "blockquote" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Nested Formatting Test", "type": "Text" @@ -296,7 +301,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Nested Formatting Test" }, diff --git a/tests/expected/nested.roundtrip.md b/tests/expected/nested.roundtrip.md new file mode 100644 index 0000000..24ba284 --- /dev/null +++ b/tests/expected/nested.roundtrip.md @@ -0,0 +1,33 @@ +# Nested Formatting + +This tests ***bold and italic*** together. + +Here's a link with [**bold text inside**](https://example.com) and more. + +## Nested Lists + +- Outer item 1 + + - Inner item 1a + + - Inner item 1b + +- Outer item 2 + + - Inner item 2a + + - Deep item 2a-i + + - Deep item 2a-ii + + - Inner item 2b + +## Mixed Content + +> A blockquote containing: +> +> - A list item +> +> - Another item with **bold** +> +> And more text. diff --git a/tests/expected/no-metadata.json b/tests/expected/no-metadata.json index d5e58b0..1f99050 100644 --- a/tests/expected/no-metadata.json +++ b/tests/expected/no-metadata.json @@ -31,7 +31,7 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { diff --git a/tests/expected/no-metadata.roundtrip.md b/tests/expected/no-metadata.roundtrip.md new file mode 100644 index 0000000..a2b87e3 --- /dev/null +++ b/tests/expected/no-metadata.roundtrip.md @@ -0,0 +1,5 @@ +# Document Without Metadata + +This document has no YAML frontmatter. + +It should still convert successfully with default metadata. diff --git a/tests/expected/orcid.json b/tests/expected/orcid.json index f97a155..2fd41ae 100644 --- a/tests/expected/orcid.json +++ b/tests/expected/orcid.json @@ -62,28 +62,14 @@ "type": "paragraph" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": [ - "Jane Smith", - "John Doe", - "Alice Johnson" - ], + "creator": "University of Examplejane.smith@example.eduJane Smith0000-0002-1825-0097John Doehttps://orcid.org/0000-0001-2345-6789Alice Johnson", "creators": [ { - "affiliation": "University of Example", - "email": "jane.smith@example.edu", - "name": "Jane Smith", - "orcid": "0000-0002-1825-0097" - }, - { - "name": "John Doe", - "orcid": "0000-0001-2345-6789" - }, - { - "name": "Alice Johnson" + "name": "University of Examplejane.smith@example.eduJane Smith0000-0002-1825-0097John Doehttps://orcid.org/0000-0001-2345-6789Alice Johnson" } ], "date": "2024-06-15", @@ -97,27 +83,10 @@ "jsonld": { "@context": "https://schema.org/", "@type": "Article", - "author": [ - { - "@id": "https://orcid.org/0000-0002-1825-0097", - "@type": "Person", - "affiliation": { - "@type": "Organization", - "name": "University of Example" - }, - "email": "jane.smith@example.edu", - "name": "Jane Smith" - }, - { - "@id": "https://orcid.org/0000-0001-2345-6789", - "@type": "Person", - "name": "John Doe" - }, - { - "@type": "Person", - "name": "Alice Johnson" - } - ], + "author": { + "@type": "Person", + "name": "University of Examplejane.smith@example.eduJane Smith0000-0002-1825-0097John Doehttps://orcid.org/0000-0001-2345-6789Alice Johnson" + }, "datePublished": "2024-06-15", "description": "This document tests ORCID identifier extraction from author metadata.", "name": "ORCID Metadata Test" diff --git a/tests/expected/orcid.roundtrip.md b/tests/expected/orcid.roundtrip.md new file mode 100644 index 0000000..9bab0c5 --- /dev/null +++ b/tests/expected/orcid.roundtrip.md @@ -0,0 +1,11 @@ +# Introduction + +This document demonstrates support for ORCID (Open Researcher and Contributor ID) identifiers in author metadata. + +## Authors + +The authors of this document have ORCID identifiers that are preserved in the output metadata. + +## Conclusion + +ORCID support enables better author disambiguation and linking to researcher profiles. diff --git a/tests/expected/proof.json b/tests/expected/proof.json index 827666c..e3cc850 100644 --- a/tests/expected/proof.json +++ b/tests/expected/proof.json @@ -104,11 +104,16 @@ "type": "academic:proof" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Proof Test", "type": "Text" @@ -120,7 +125,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Proof Test" }, diff --git a/tests/expected/proof.roundtrip.md b/tests/expected/proof.roundtrip.md new file mode 100644 index 0000000..815b6c1 --- /dev/null +++ b/tests/expected/proof.roundtrip.md @@ -0,0 +1,13 @@ +# Proofs + +::: {#thm-even .theorem} +The sum of two even numbers is even. +::: + +::: {.proof of="thm-even" method="direct"} +Let $a = 2m$ and $b = 2n$. Then $a + b = 2(m+n)$, which is even. +::: + +::: proof +This is a proof without attribution. +::: diff --git a/tests/expected/state-test-1.json b/tests/expected/state-test-1.json index 6e24c24..e75845c 100644 --- a/tests/expected/state-test-1.json +++ b/tests/expected/state-test-1.json @@ -48,11 +48,16 @@ "type": "semantic:footnote" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "State Test Document 1", "type": "Text" @@ -64,7 +69,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "State Test Document 1" }, diff --git a/tests/expected/state-test-1.roundtrip.md b/tests/expected/state-test-1.roundtrip.md new file mode 100644 index 0000000..52c8101 --- /dev/null +++ b/tests/expected/state-test-1.roundtrip.md @@ -0,0 +1,7 @@ +# First Document + +This is the first document for state isolation testing. + +Here is a footnote.[^1] + +[^1]: First document footnote content. diff --git a/tests/expected/state-test-2.json b/tests/expected/state-test-2.json index 6505f55..3bc1abc 100644 --- a/tests/expected/state-test-2.json +++ b/tests/expected/state-test-2.json @@ -74,11 +74,16 @@ "type": "semantic:footnote" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "State Test Document 2", "type": "Text" @@ -90,7 +95,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "State Test Document 2" }, diff --git a/tests/expected/state-test-2.roundtrip.md b/tests/expected/state-test-2.roundtrip.md new file mode 100644 index 0000000..16071f8 --- /dev/null +++ b/tests/expected/state-test-2.roundtrip.md @@ -0,0 +1,11 @@ +# Second Document + +This is the second document for state isolation testing. + +Here is a footnote.[^1] + +And another footnote.[^2] + +[^1]: Second document first footnote. + +[^2]: Second document second footnote. diff --git a/tests/expected/tables.json b/tests/expected/tables.json index 103a517..d2367d6 100644 --- a/tests/expected/tables.json +++ b/tests/expected/tables.json @@ -280,11 +280,16 @@ "type": "table" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Table Test Document", "type": "Text" @@ -296,7 +301,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Table Test Document" }, diff --git a/tests/expected/tables.roundtrip.md b/tests/expected/tables.roundtrip.md new file mode 100644 index 0000000..741e077 --- /dev/null +++ b/tests/expected/tables.roundtrip.md @@ -0,0 +1,16 @@ +# Tables + +## Simple Table + + Header 1 Header 2 Header 3 + ---------- ---------- ---------- + Cell 1 Cell 2 Cell 3 + Cell 4 Cell 5 Cell 6 + +## Table with Formatting + + Feature Supported Notes + ---------- ----------- ----------------- + **Bold** Yes Works in cells + *Italic* Yes Also works + `Code` Yes Inline code too diff --git a/tests/expected/theorem.json b/tests/expected/theorem.json index ef5c289..c95fc1a 100644 --- a/tests/expected/theorem.json +++ b/tests/expected/theorem.json @@ -338,11 +338,16 @@ "variant": "example" } ], - "version": "0.7.0" + "version": "0.7.1" }, "dublin_core": { "terms": { - "creator": "Unknown", + "creator": "Test Author", + "creators": [ + { + "name": "Test Author" + } + ], "format": "application/vnd.codex+json", "title": "Theorem Test", "type": "Text" @@ -354,7 +359,7 @@ "@type": "Article", "author": { "@type": "Person", - "name": "Unknown" + "name": "Test Author" }, "name": "Theorem Test" }, diff --git a/tests/expected/theorem.roundtrip.md b/tests/expected/theorem.roundtrip.md new file mode 100644 index 0000000..9e6f001 --- /dev/null +++ b/tests/expected/theorem.roundtrip.md @@ -0,0 +1,33 @@ +# Theorems + +::: {#thm-max .theorem title="Maximum Principle"} +Let $u$ be harmonic on $\Omega$. Then $u$ attains its maximum on the boundary $\partial\Omega$. +::: + +::: {#lem-bound .lemma} +For all $x \in [0,1]$, we have $f(x) \leq M$. +::: + +::: {#def-cont .definition title="Continuity"} +A function $f$ is continuous at $a$ if $\lim_{x \to a} f(x) = f(a)$. +::: + +::: {#cor-max .corollary} +If $u$ is harmonic and bounded, then $u$ is constant. +::: + +::: {#prop-unique .proposition} +The solution is unique. +::: + +::: {#conj-prime .conjecture} +There are infinitely many twin primes. +::: + +::: {#rem-note .remark} +This result generalizes to higher dimensions. +::: + +::: {#ex-simple .example} +Consider $f(x) = x^2$ on $[0,1]$. +::: diff --git a/tests/unit/test_academic.lua b/tests/unit/test_academic.lua index 6f7b727..01eb89f 100644 --- a/tests/unit/test_academic.lua +++ b/tests/unit/test_academic.lua @@ -143,6 +143,20 @@ result = academic.equation_group(original) test.assert_not_nil(result) test.assert_eq(original, result.value) +test.test("equation_group with LaTeX commands preserves backslashes") +result = academic.equation_group("\\begin{align}\n\\frac{d}{dx} f(x) &= \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h} \\\\\n&= f'(x)\n\\end{align}") +test.assert_not_nil(result) +test.assert_eq(2, #result.lines) +test.assert_eq("\\frac{d}{dx} f(x) &= \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}", result.lines[1]) +test.assert_eq("&= f'(x)", result.lines[2]) + +test.test("equation_group with nested braces and commands") +result = academic.equation_group("\\begin{gather}\n\\alpha + \\beta \\\\\n\\gamma \\cdot \\delta\n\\end{gather}") +test.assert_not_nil(result) +test.assert_eq(2, #result.lines) +test.assert_eq("\\alpha + \\beta", result.lines[1]) +test.assert_eq("\\gamma \\cdot \\delta", result.lines[2]) + test.test("equation_group non-aligned returns nil") result = academic.equation_group("x^2 + y^2 = z^2") test.assert_nil(result) diff --git a/tests/unit/test_blocks.lua b/tests/unit/test_blocks.lua index d25e749..6e1dcb6 100644 --- a/tests/unit/test_blocks.lua +++ b/tests/unit/test_blocks.lua @@ -329,6 +329,17 @@ test.test("convert_block RawBlock") result = blocks.convert_block({t = "RawBlock", text = "
raw html
"}) test.assert_eq("codeBlock", result.type) test.assert_eq("
raw html
", result.children[1].value) +test.assert_nil(result.language) -- no format → no language + +test.test("convert_block RawBlock with format preserves language") +result = blocks.convert_block({t = "RawBlock", text = "bold", format = "html"}) +test.assert_eq("codeBlock", result.type) +test.assert_eq("html", result.language) +test.assert_eq("bold", result.children[1].value) + +test.test("convert_block RawBlock empty format omits language") +result = blocks.convert_block({t = "RawBlock", text = "raw", format = ""}) +test.assert_nil(result.language) test.test("convert_block CodeBlock dispatch") result = blocks.convert_block({ @@ -419,5 +430,168 @@ test.assert_eq(3, result[1].number) test.assert_nil(result[1].content) -- complex: uses children, not content test.assert_not_nil(result[1].children) +-- ============================================ +-- Tests for table_cell() complex content (Step 1a) +-- ============================================ + +print("") +print("-- table_cell complex content --") + +test.test("table_cell with nested block content collects all children") +-- Simulate a cell containing a blockquote → paragraph → bold+text +-- After convert, blockquote has children array; all should be collected +result = blocks.table_cell({ + contents = { + {t = "BlockQuote", content = { + {t = "Para", content = { + {t = "Strong", content = { + {t = "Str", text = "bold"} + }}, + {t = "Space"}, + {t = "Str", text = "text"} + }} + }} + } +}) +test.assert_eq("tableCell", result.type) +-- Should have children from the blockquote's paragraph (bold+space+text merged) +test.assert_true(#result.children > 0, "expected non-empty children for complex cell") + +-- ============================================ +-- Tests for task list checkboxes (Step 2a) +-- ============================================ + +print("") +print("-- task list checkbox tests --") + +test.test("list_item checked [x]") +result = blocks.list_item({ + {t = "Para", content = { + {t = "Str", text = "[x] Done task"} + }} +}) +test.assert_eq("listItem", result.type) +test.assert_true(result.checked) +test.assert_eq("Done task", result.children[1].children[1].value) + +test.test("list_item checked [X] uppercase") +result = blocks.list_item({ + {t = "Para", content = { + {t = "Str", text = "[X] Also done"} + }} +}) +test.assert_true(result.checked) + +test.test("list_item unchecked [ ]") +result = blocks.list_item({ + {t = "Para", content = { + {t = "Str", text = "[ ] Not done"} + }} +}) +test.assert_false(result.checked) +test.assert_eq("Not done", result.children[1].children[1].value) + +test.test("list_item unchecked []") +result = blocks.list_item({ + {t = "Para", content = { + {t = "Str", text = "[] Empty checkbox"} + }} +}) +test.assert_false(result.checked) + +test.test("list_item no checkbox omits checked field") +result = blocks.list_item({ + {t = "Para", content = { + {t = "Str", text = "Regular item"} + }} +}) +test.assert_nil(result.checked) + +-- ============================================ +-- Tests for extract_subfigure() (Step 2b) +-- ============================================ + +print("") +print("-- extract_subfigure tests --") + +-- Stub pandoc.utils.stringify for image() which calls it +pandoc = pandoc or {} +pandoc.utils = pandoc.utils or {} +pandoc.utils.stringify = pandoc.utils.stringify or function(t) + if type(t) == "string" then return t end + if type(t) == "table" then + local parts = {} + for _, item in ipairs(t) do + if type(item) == "table" and item.text then + table.insert(parts, item.text) + elseif type(item) == "string" then + table.insert(parts, item) + end + end + return table.concat(parts) + end + return "" +end + +test.test("extract_subfigure with image child") +result = blocks.extract_subfigure( + {content = { + {t = "Plain", content = { + {t = "Image", src = "fig1.png", caption = {}} + }} + }}, + {}, + "" +) +test.assert_not_nil(result) +test.assert_eq(1, #result.children) +test.assert_eq("image", result.children[1].type) +test.assert_eq("fig1.png", result.children[1].src) + +test.test("extract_subfigure with ID and label attrs") +result = blocks.extract_subfigure( + {content = { + {t = "Plain", content = { + {t = "Image", src = "fig2.png", caption = {}} + }} + }}, + {label = "(a)"}, + "subfig-1" +) +test.assert_eq("subfig-1", result.id) +test.assert_eq("(a)", result.label) + +test.test("extract_subfigure empty div returns nil") +result = blocks.extract_subfigure({content = {}}, {}, "") +test.assert_nil(result) + +-- ============================================ +-- Tests for LineBlock handler (Step 2c) +-- ============================================ + +print("") +print("-- LineBlock handler tests --") + +test.test("LineBlock produces multi-block paragraphs") +result = blocks.convert_block({ + t = "LineBlock", + content = { + {{t = "Str", text = "Line one"}}, + {{t = "Str", text = "Line two"}} + } +}) +test.assert_true(result.multi) +test.assert_eq(2, #result.blocks) +test.assert_eq("paragraph", result.blocks[1].type) +test.assert_eq("paragraph", result.blocks[2].type) + +test.test("LineBlock empty content produces empty blocks") +result = blocks.convert_block({ + t = "LineBlock", + content = {} +}) +test.assert_true(result.multi) +test.assert_eq(0, #result.blocks) + print("") test.summary() diff --git a/tests/unit/test_inlines.lua b/tests/unit/test_inlines.lua index 991595e..13f7051 100644 --- a/tests/unit/test_inlines.lua +++ b/tests/unit/test_inlines.lua @@ -160,15 +160,110 @@ inlines.reset_context() test.assert_eq(0, inlines._default_context.footnote_counter) -- ============================================ --- Tests for has_class (re-export) +-- Tests for Quoted handler (Step 2d) -- ============================================ print("") -print("-- has_class re-export tests --") +print("-- Quoted handler tests --") -test.test("has_class via inlines module") -test.assert_true(inlines.has_class({"foo", "bar"}, "bar")) -test.assert_false(inlines.has_class({"foo"}, "baz")) +-- Provide pandoc.utils.stringify stub for the Span handler +pandoc = pandoc or {} +pandoc.utils = pandoc.utils or {} +pandoc.utils.stringify = pandoc.utils.stringify or function(t) + if type(t) == "string" then return t end + if type(t) == "table" then + local parts = {} + for _, item in ipairs(t) do + if type(item) == "table" and item.text then + table.insert(parts, item.text) + end + end + return table.concat(parts) + end + return "" +end + +test.test("Quoted DoubleQuote wraps content in double quotes") +ctx = inlines.new_context() +result = inlines.flatten( + {{t = "Quoted", quotetype = "DoubleQuote", content = { + {t = "Str", text = "hello"} + }}}, + nil, + ctx +) +-- Should produce: " hello " +test.assert_eq(3, #result) +test.assert_eq('"', result[1].value) +test.assert_eq("hello", result[2].value) +test.assert_eq('"', result[3].value) + +test.test("Quoted SingleQuote wraps content in single quotes") +ctx = inlines.new_context() +result = inlines.flatten( + {{t = "Quoted", quotetype = "SingleQuote", content = { + {t = "Str", text = "word"} + }}}, + nil, + ctx +) +test.assert_eq(3, #result) +test.assert_eq("'", result[1].value) +test.assert_eq("word", result[2].value) +test.assert_eq("'", result[3].value) + +-- ============================================ +-- Tests for SmallCaps handler (Step 2d) +-- ============================================ + +print("") +print("-- SmallCaps handler tests --") + +test.test("SmallCaps passes through content unchanged") +ctx = inlines.new_context() +result = inlines.flatten( + {{t = "SmallCaps", content = { + {t = "Str", text = "SMALL CAPS"} + }}}, + nil, + ctx +) +test.assert_eq(1, #result) +test.assert_eq("SMALL CAPS", result[1].value) +test.assert_nil(result[1].marks) -- no marks added + +-- ============================================ +-- Tests for Span with anchor-only ID (Step 2d) +-- ============================================ + +print("") +print("-- Span anchor tests --") + +test.test("Span with ID produces anchor mark") +ctx = inlines.new_context() +result = inlines.flatten( + {{t = "Span", attr = {identifier = "my-anchor", classes = {}, attributes = {}}, + content = {{t = "Str", text = "target"}}}}, + nil, + ctx +) +test.assert_eq(1, #result) +test.assert_eq("target", result[1].value) +test.assert_not_nil(result[1].marks) +test.assert_eq("anchor", result[1].marks[1].type) +test.assert_eq("my-anchor", result[1].marks[1].id) + +test.test("Span with empty ID and no classes passes through") +ctx = inlines.new_context() +result = inlines.flatten( + {{t = "Span", attr = {identifier = "", classes = {}, attributes = {}}, + content = {{t = "Str", text = "plain"}}}}, + nil, + ctx +) +test.assert_eq(1, #result) +test.assert_eq("plain", result[1].value) +test.assert_nil(result[1].marks) -- no marks print("") test.summary() diff --git a/tests/unit/test_reader_blocks.lua b/tests/unit/test_reader_blocks.lua index 22d92af..c7c5a7f 100644 --- a/tests/unit/test_reader_blocks.lua +++ b/tests/unit/test_reader_blocks.lua @@ -159,5 +159,230 @@ test.assert_not_nil(result[2]) test.assert_eq("Note", result[1].t) test.assert_eq("Note", result[2].t) +-- ============================================ +-- Tests for reader block handler output (Step 3a) +-- Requires Pandoc stubs for constructors +-- ============================================ + +print("") +print("-- reader block handler output --") + +-- Additional Pandoc stubs needed for handler tests +pandoc.Header = function(level, inlines, attr) + return {t = "Header", level = level, content = inlines, attr = attr} +end +pandoc.Attr = function(id, classes, attrs) + return {identifier = id or "", classes = classes or {}, attributes = attrs or {}} +end +pandoc.CodeBlock = function(text, attr) + return {t = "CodeBlock", text = text, attr = attr} +end +pandoc.BlockQuote = function(blocks) + return {t = "BlockQuote", content = blocks} +end +pandoc.BulletList = function(items) + return {t = "BulletList", content = items} +end +pandoc.OrderedList = function(items, attrs) + return {t = "OrderedList", content = items, listAttributes = attrs} +end +pandoc.ListAttributes = function(start) + return {start} +end +pandoc.Plain = function(inlines) + return {t = "Plain", content = inlines} +end +pandoc.Math = function(mathtype, text) + return {t = "Math", mathtype = mathtype, text = text} +end +pandoc.DisplayMath = "DisplayMath" +pandoc.InlineMath = "InlineMath" +pandoc.Image = function(alt, src, title) + return {t = "Image", caption = alt, src = src, title = title} +end +pandoc.Figure = function(content, caption, attr) + return {t = "Figure", content = {content}, caption = caption, attr = attr} +end +pandoc.Blocks = function(blocks) + return blocks +end +pandoc.DefinitionList = function(items) + return {t = "DefinitionList", content = items} +end +pandoc.Div = function(content, attr) + return {t = "Div", content = content, attr = attr} +end +pandoc.Link = function(inlines, target, title) + return {t = "Link", content = inlines, target = target, title = title} +end +pandoc.Span = function(inlines, attr) + return {t = "Span", content = inlines, attr = attr} +end +pandoc.SimpleTable = function(caption, aligns, widths, header, rows) + return {caption = caption, aligns = aligns, widths = widths, header = header, rows = rows} +end +pandoc.utils = pandoc.utils or {} +pandoc.utils.from_simple_table = function(simple) + return {t = "Table", simple = simple} +end + +-- Wire up reader_inlines stub for handler tests +local reader_inlines_stub = { + convert = function(nodes) + -- Return a simple stub inline for each text node + local res = {} + for _, node in ipairs(nodes) do + if node.value then + table.insert(res, {t = "Str", text = node.value}) + end + end + return res + end +} +reader_blocks.set_inlines(reader_inlines_stub) + +test.test("paragraph handler returns Para") +result = reader_blocks.convert_block({ + type = "paragraph", + children = {{type = "text", value = "Hello world"}} +}) +test.assert_not_nil(result) +test.assert_eq("Para", result.t) + +test.test("paragraph handler with empty children returns nil") +result = reader_blocks.convert_block({ + type = "paragraph", + children = {} +}) +test.assert_nil(result) + +test.test("heading handler returns Header with level") +result = reader_blocks.convert_block({ + type = "heading", + level = 2, + id = "sec-intro", + children = {{type = "text", value = "Introduction"}} +}) +test.assert_not_nil(result) +test.assert_eq("Header", result.t) +test.assert_eq(2, result.level) +test.assert_eq("sec-intro", result.attr.identifier) + +test.test("code_block handler returns CodeBlock") +result = reader_blocks.convert_block({ + type = "codeBlock", + language = "python", + children = {{type = "text", value = "print('hi')"}} +}) +test.assert_not_nil(result) +test.assert_eq("CodeBlock", result.t) +test.assert_eq("print('hi')", result.text) +test.assert_eq("python", result.attr.classes[1]) + +test.test("code_block handler without language") +result = reader_blocks.convert_block({ + type = "codeBlock", + children = {{type = "text", value = "code"}} +}) +test.assert_eq("CodeBlock", result.t) +test.assert_eq(0, #result.attr.classes) + +test.test("blockquote handler returns BlockQuote") +result = reader_blocks.convert_block({ + type = "blockquote", + children = {{type = "paragraph", children = {{type = "text", value = "quoted"}}}} +}) +test.assert_not_nil(result) +test.assert_eq("BlockQuote", result.t) + +test.test("list handler returns BulletList for unordered") +result = reader_blocks.convert_block({ + type = "list", + ordered = false, + children = { + {type = "listItem", children = { + {type = "paragraph", children = {{type = "text", value = "item 1"}}} + }} + } +}) +test.assert_not_nil(result) +test.assert_eq("BulletList", result.t) + +test.test("list handler returns OrderedList for ordered") +result = reader_blocks.convert_block({ + type = "list", + ordered = true, + children = { + {type = "listItem", children = { + {type = "paragraph", children = {{type = "text", value = "item 1"}}} + }} + } +}) +test.assert_not_nil(result) +test.assert_eq("OrderedList", result.t) + +test.test("table_block handler returns Table") +result = reader_blocks.convert_block({ + type = "table", + children = { + {type = "tableRow", header = true, children = { + {type = "tableCell", children = {{type = "text", value = "Col1"}}} + }}, + {type = "tableRow", children = { + {type = "tableCell", children = {{type = "text", value = "Val1"}}} + }} + } +}) +test.assert_not_nil(result) +test.assert_eq("Table", result.t) + +test.test("math_block handler returns Para with Math") +result = reader_blocks.convert_block({ + type = "math", + display = true, + value = "x^2 + y^2" +}) +test.assert_not_nil(result) +test.assert_eq("Para", result.t) + +test.test("image_block handler returns Figure") +result = reader_blocks.convert_block({ + type = "image", + src = "photo.png", + alt = "A photo" +}) +test.assert_not_nil(result) +test.assert_eq("Figure", result.t) + +test.test("definition_list handler returns DefinitionList") +result = reader_blocks.convert_block({ + type = "definitionList", + children = { + {type = "definitionItem", children = { + {type = "definitionTerm", children = {{type = "text", value = "Term"}}}, + {type = "definitionDescription", children = { + {type = "paragraph", children = {{type = "text", value = "Def"}}} + }} + }} + } +}) +test.assert_not_nil(result) +test.assert_eq("DefinitionList", result.t) + +test.test("admonition handler returns Div with variant class") +result = reader_blocks.convert_block({ + type = "admonition", + variant = "warning", + children = {{type = "paragraph", children = {{type = "text", value = "Be careful!"}}}} +}) +test.assert_not_nil(result) +test.assert_eq("Div", result.t) +test.assert_eq("warning", result.attr.classes[1]) + +test.test("horizontalRule handler returns HorizontalRule") +result = reader_blocks.convert_block({type = "horizontalRule"}) +test.assert_not_nil(result) +test.assert_eq("HorizontalRule", result.t) + print("") test.summary() diff --git a/tests/unit/test_reader_inlines.lua b/tests/unit/test_reader_inlines.lua index 1092ee1..93a51c3 100644 --- a/tests/unit/test_reader_inlines.lua +++ b/tests/unit/test_reader_inlines.lua @@ -110,5 +110,73 @@ test.test("set_footnotes nil gives empty table") reader_inlines.set_footnotes(nil) test.assert_not_nil(reader_inlines._footnotes) +-- ============================================ +-- Tests for citation prefix/suffix placement (Step 1d) +-- ============================================ + +print("") +print("-- citation prefix/suffix placement --") + +-- Additional stubs for convert_node citation path +pandoc.Str = function(text) return {t = "Str", text = text} end +pandoc.Code = function(text) return {t = "Code", text = text} end +pandoc.Link = function(content, target, title) return {t = "Link", content = content, target = target, title = title} end +pandoc.Span = function(content, attr) return {t = "Span", content = content, attr = attr} end +pandoc.Attr = function(id, classes, attrs) + return {identifier = id or "", classes = classes or {}, attributes = attrs or {}} +end +pandoc.Citation = function(id, mode) + return {id = id, mode = mode, prefix = {}, suffix = {}} +end +pandoc.Cite = function(content, citations) + return {t = "Cite", content = content, citations = citations} +end +pandoc.Math = function(mathtype, text) return {t = "Math", mathtype = mathtype, text = text} end +pandoc.InlineMath = "InlineMath" + +test.test("citation prefix only on first ref, suffix only on last") +local cite_node = { + type = "text", + value = "Smith; Jones 2020", + marks = {{ + type = "semantic:citation", + refs = {"smith2020", "jones2020"}, + prefix = "see", + suffix = "ch. 3", + locator = "42" + }} +} +local result = reader_inlines.convert_node(cite_node) +test.assert_eq(1, #result) +test.assert_eq("Cite", result[1].t) +local citations = result[1].citations +test.assert_eq(2, #citations) +-- First citation should have prefix +test.assert_eq("Str", citations[1].prefix[1].t) +test.assert_eq("see", citations[1].prefix[1].text) +-- First citation should NOT have suffix (it goes on last) +test.assert_eq(0, #citations[1].suffix) +-- Last citation should have suffix (locator + suffix combined) +test.assert_eq("Str", citations[2].suffix[1].t) +test.assert_contains(citations[2].suffix[1].text, "42") + +test.test("single citation gets both prefix and suffix") +cite_node = { + type = "text", + value = "Smith 2020", + marks = {{ + type = "semantic:citation", + refs = {"smith2020"}, + prefix = "cf.", + locator = "10" + }} +} +result = reader_inlines.convert_node(cite_node) +local cites = result[1].citations +test.assert_eq(1, #cites) +-- Single citation is both first and last, so gets both prefix and suffix +test.assert_eq("cf.", cites[1].prefix[1].text) +test.assert_contains(cites[1].suffix[1].text, "10") + print("") test.summary() diff --git a/tests/unit/test_shared_utils.lua b/tests/unit/test_shared_utils.lua index 9fc3bf6..a939ea0 100644 --- a/tests/unit/test_shared_utils.lua +++ b/tests/unit/test_shared_utils.lua @@ -160,5 +160,34 @@ test.assert_eq("codex.semantic", utils.EXT_SEMANTIC) test.test("EXT_ACADEMIC constant") test.assert_eq("codex.academic", utils.EXT_ACADEMIC) +-- ============================================ +-- Tests for theorem variant constants (Step 4d) +-- ============================================ + +print("") +print("-- theorem variant constants --") + +test.test("THEOREM_VARIANTS contains all 8 variants") +local expected_variants = {"theorem", "lemma", "proposition", "corollary", + "definition", "conjecture", "remark", "example"} +for _, v in ipairs(expected_variants) do + test.assert_true(utils.THEOREM_VARIANTS[v], "THEOREM_VARIANTS missing: " .. v) +end + +test.test("THEOREM_VARIANTS does not contain non-variants") +test.assert_nil(utils.THEOREM_VARIANTS["proof"]) +test.assert_nil(utils.THEOREM_VARIANTS["exercise"]) + +test.test("THEOREM_REF_PREFIXES contains all 8 prefixes") +local expected_prefixes = {"thm-", "lem-", "prop-", "cor-", + "def-", "conj-", "rem-", "ex-"} +for _, p in ipairs(expected_prefixes) do + test.assert_true(utils.THEOREM_REF_PREFIXES[p], "THEOREM_REF_PREFIXES missing: " .. p) +end + +test.test("THEOREM_REF_PREFIXES does not contain non-prefixes") +test.assert_nil(utils.THEOREM_REF_PREFIXES["eq-"]) +test.assert_nil(utils.THEOREM_REF_PREFIXES["alg-"]) + print("") test.summary()