Skip to content

Commit 3dc8258

Browse files
authored
Merge pull request #1946 from Open-Source-Legal/claude/cool-fermat-kdCwm
Fix corpus CAML article rendering: stray-block leak and collapsed columns
2 parents 0f08057 + da4989a commit 3dc8258

8 files changed

Lines changed: 407 additions & 4 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- Fixed two rendering bugs in auto-generated corpus CAML articles (`Readme.CAML`) that surfaced on freshly-branded corpora:
2+
- **Raw block text leak** (e.g. `corpus-stats` rendering as a stray `- documents | Documents` / `- annotations | Annotations` / `::::` bullet list). `@os-legal/caml`'s depth-specific tokenizer cannot close a `::::` block fence placed at the top level (outside a `::: chapter`), so it leaked the block body — including the literal `::::` — as prose. Added `frontend/src/components/corpuses/caml/normalizeCamlSource.ts` (`normalizeCamlSource` / `parseCamlArticle`), which wraps stray top-level block fences in a synthetic chapter before parsing, and wired it into `CorpusArticleView.tsx` and `CamlArticleEditor.tsx`. This repairs already-stored articles at render time as well as new ones.
3+
- **Collapsed content column** (prose wrapping one word per line; CTA buttons wrapping mid-word, e.g. "Contact" / "Us"). `CamlArticleFrame.tsx` capped every section to `readingMeasure` (720px) while `@os-legal/caml-react` styles dark/gradient chapters full-bleed via `padding: 3rem calc((100% - 720px) / 2 + 2rem)`, whose percentage resolves against the wider article width — so on wide viewports the horizontal padding collapsed the capped section's content box toward zero. Neutralized the full-bleed padding with a flat gutter (`CamlArticleFrame.tsx`).
4+
- Hardened the CAML Article Writer prompt (`opencontractserver/corpuses/caml_authoring.py`) to require every `::::` block be nested inside a `::: chapter`, reducing mis-nested LLM output at the source; propagated to existing databases via `agents/migrations/0016_update_caml_article_writer_prompt.py`.

frontend/src/components/corpuses/CamlArticleEditor.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import {
3333
UploadDocumentInputProps,
3434
UploadDocumentOutputProps,
3535
} from "../../graphql/mutations";
36-
import { parseCaml } from "@os-legal/caml";
3736
import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react";
37+
import { parseCamlArticle } from "./caml/normalizeCamlSource";
3838
import { useCamlComponentRenderer } from "../../hooks/useCamlComponentRenderer";
3939
import { buildComponentProseFence } from "../../utils/camlComponents";
4040
import { CAML_COMPONENTS } from "../../utils/camlComponentRegistry";
@@ -466,7 +466,7 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
466466
// Parse content for preview
467467
const parsedDocument = useMemo(() => {
468468
try {
469-
return parseCaml(content);
469+
return parseCamlArticle(content);
470470
} catch {
471471
return null;
472472
}

frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import {
2626
GetCorpusArticleOutput,
2727
} from "../../../graphql/queries";
2828
import { CorpusType } from "../../../types/graphql-api";
29-
import { parseCaml } from "@os-legal/caml";
3029
import type { CamlDocument } from "@os-legal/caml";
30+
import { parseCamlArticle } from "../caml/normalizeCamlSource";
3131
import {
3232
CAML_ARTICLE_FILENAME,
3333
TABLET_BREAKPOINT,
@@ -326,7 +326,7 @@ export const CorpusArticleView: React.FC<CorpusArticleViewProps> = ({
326326
const parsedDocument: CamlDocument | null = useMemo(() => {
327327
if (!camlContent) return null;
328328
try {
329-
return parseCaml(camlContent);
329+
return parseCamlArticle(camlContent);
330330
} catch (err) {
331331
console.error("Failed to parse CAML:", err);
332332
return null;

frontend/src/components/corpuses/caml/CamlArticleFrame.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ export const CamlArticleFrame = styled.div<{ $bottomInset?: string }>`
6565
article > section {
6666
max-width: ${CORPUS_BREAKPOINTS.readingMeasure}px;
6767
margin-inline: auto;
68+
69+
/* Neutralize the library's full-bleed dark/gradient padding so it cannot
70+
collapse the content column. @os-legal/caml-react styles theme:dark /
71+
gradient chapters full-bleed via "max-width: 100%" + "padding: 3rem
72+
calc((100% - 720px) / 2 + 2rem)", where the percentage resolves against
73+
the (wider) article width W, not the section. Capping the section to
74+
readingMeasure above leaves that padding intact, so the content box works
75+
out to ~(2*readingMeasure - W): for any viewport wider than ~readingMeasure
76+
it shrinks, and around W ~ 1300px it collapses to a single word per line
77+
(and CTA buttons wrap mid-word, e.g. "Contact" / "Us"). A flat gutter that
78+
matches the light-chapter measure keeps dark/gradient chapters as centered
79+
boxes instead. The mobile @media block below overrides this with
80+
safe-area-aware gutters. */
81+
padding-left: ${CORPUS_SPACING[6]};
82+
padding-right: ${CORPUS_SPACING[6]};
6883
}
6984
7085
article > section p {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseCaml } from "@os-legal/caml";
3+
import { normalizeCamlSource, parseCamlArticle } from "../normalizeCamlSource";
4+
5+
/**
6+
* Helper: list block types per chapter, so assertions read as a structural
7+
* snapshot rather than poking at deep object shapes.
8+
*/
9+
function blockTypesByChapter(source: string): string[][] {
10+
return parseCaml(source).chapters.map((c) => c.blocks.map((b) => b.type));
11+
}
12+
13+
const FRONTMATTER = [
14+
"---",
15+
'version: "1.0"',
16+
"hero:",
17+
' title: ["Fort Worth"]',
18+
"---",
19+
"",
20+
].join("\n");
21+
22+
describe("normalizeCamlSource", () => {
23+
it("returns source unchanged when there is no depth-4 fence (fast path)", () => {
24+
const src =
25+
FRONTMATTER +
26+
[
27+
"::: chapter {#intro}",
28+
"## Intro",
29+
"Just prose, no blocks.",
30+
":::",
31+
"",
32+
].join("\n");
33+
expect(normalizeCamlSource(src)).toBe(src);
34+
});
35+
36+
it("leaves correctly-nested blocks untouched", () => {
37+
const src =
38+
FRONTMATTER +
39+
[
40+
"::: chapter {theme: dark}",
41+
"## Major Projects",
42+
"",
43+
":::: cards {columns: 2}",
44+
"- **Infrastructure** | Major | #0f766e",
45+
" Body text.",
46+
"::::",
47+
"",
48+
":::",
49+
"",
50+
].join("\n");
51+
expect(normalizeCamlSource(src)).toBe(src);
52+
});
53+
54+
it("does not touch YAML frontmatter even when the body needs wrapping", () => {
55+
const src =
56+
FRONTMATTER +
57+
[":::: corpus-stats", "- documents | Documents", "::::", ""].join("\n");
58+
const normalized = normalizeCamlSource(src);
59+
expect(normalized.startsWith(FRONTMATTER.trimEnd())).toBe(true);
60+
expect(parseCaml(normalized).frontmatter.hero).toBeTruthy();
61+
});
62+
63+
it("is idempotent", () => {
64+
const src =
65+
FRONTMATTER +
66+
[
67+
":::: corpus-stats",
68+
"- documents | Documents",
69+
"- annotations | Annotations",
70+
"::::",
71+
"",
72+
].join("\n");
73+
const once = normalizeCamlSource(src);
74+
const twice = normalizeCamlSource(once);
75+
expect(twice).toBe(once);
76+
});
77+
});
78+
79+
describe("parseCamlArticle — repairs mis-nested blocks the raw parser leaks", () => {
80+
it("recovers a top-level corpus-stats block (the canonical leak)", () => {
81+
const src =
82+
FRONTMATTER +
83+
[
84+
":::: corpus-stats",
85+
"- documents | Documents",
86+
"- annotations | Annotations",
87+
"::::",
88+
"",
89+
].join("\n");
90+
91+
// Raw parser leaks the block body (incl. literal ::::) as prose.
92+
const rawBlocks = blockTypesByChapter(src);
93+
expect(rawBlocks).toEqual([["prose"]]);
94+
const leakedProse = parseCaml(src).chapters[0].blocks[0];
95+
expect(leakedProse.type).toBe("prose");
96+
expect((leakedProse as { content: string }).content).toContain("::::");
97+
98+
// parseCamlArticle wraps it so the block is recognised.
99+
const doc = parseCamlArticle(src);
100+
const block = doc.chapters[0].blocks[0];
101+
expect(block.type).toBe("corpus-stats");
102+
expect((block as { items: unknown[] }).items).toHaveLength(2);
103+
});
104+
105+
it("recovers the screenshot scenario: dark cards chapter then a stray corpus-stats", () => {
106+
const src =
107+
FRONTMATTER +
108+
[
109+
"::: chapter {theme: dark}",
110+
"## Major Projects",
111+
"",
112+
":::: cards {columns: 2}",
113+
"- **Infrastructure** | Major | #0f766e",
114+
" Body.",
115+
"::::",
116+
"",
117+
":::",
118+
"",
119+
"::: chapter {#documentation}",
120+
"## Documentation",
121+
"Closing prose.",
122+
":::",
123+
"",
124+
":::: corpus-stats",
125+
"- documents | Documents",
126+
"- annotations | Annotations",
127+
"::::",
128+
"",
129+
].join("\n");
130+
131+
const doc = parseCamlArticle(src);
132+
const types = doc.chapters.map((c) => c.blocks.map((b) => b.type));
133+
expect(types).toEqual([["cards"], ["prose"], ["corpus-stats"]]);
134+
});
135+
136+
it("adopts a run of stray blocks with prose between them into one chapter", () => {
137+
const src =
138+
FRONTMATTER +
139+
[
140+
":::: pills",
141+
"- 247 | **Docs** | Q4",
142+
"::::",
143+
"",
144+
"Mid prose between blocks.",
145+
"",
146+
":::: cta",
147+
"- [View](#x) {primary}",
148+
"::::",
149+
"",
150+
].join("\n");
151+
152+
const doc = parseCamlArticle(src);
153+
expect(doc.chapters).toHaveLength(1);
154+
expect(doc.chapters[0].blocks.map((b) => b.type)).toEqual([
155+
"pills",
156+
"prose",
157+
"cta",
158+
]);
159+
});
160+
161+
it("recovers a stray tabs block that itself nests depth-5 fences", () => {
162+
const src =
163+
FRONTMATTER +
164+
[
165+
":::: tabs",
166+
'::::: tab {label: "US", color: #0f766e}',
167+
"#### United States",
168+
"Federal regulations analyzed.",
169+
":::::",
170+
"::::",
171+
"",
172+
].join("\n");
173+
174+
const doc = parseCamlArticle(src);
175+
expect(doc.chapters[0].blocks.map((b) => b.type)).toEqual(["tabs"]);
176+
});
177+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* CAML source normalization — repair stray top-level block fences before parsing.
3+
*
4+
* WHY THIS EXISTS
5+
* ---------------
6+
* `@os-legal/caml`'s tokenizer is depth-specific: the document body is scanned
7+
* for depth-3 (`:::`) fences, and only a chapter's *interior* is scanned for
8+
* depth-4 (`::::`) block fences. A `:::: <block>` fence written at the top level
9+
* (with no enclosing `::: chapter`) can never be closed by the depth-3
10+
* tokenizer — the `::::` close line fails its `^:{3}\s*$` close pattern — so the
11+
* parser's unclosed-fence recovery re-emits the block body *and the literal
12+
* `::::`* as a prose token. The result renders as a stray markdown bullet list
13+
* with a dangling `::::`. The canonical symptom is the `corpus-stats` block:
14+
*
15+
* :::: corpus-stats
16+
* - documents | Documents
17+
* - annotations | Annotations
18+
* ::::
19+
*
20+
* leaking to screen as "• documents | Documents" / "• annotations | Annotations
21+
* ::::".
22+
*
23+
* The backend CAML authoring guide presents every block example as a bare
24+
* `::::` fence, so LLM-authored `Readme.CAML` articles frequently emit blocks at
25+
* the top level. Rather than fork the pinned parser, we wrap any run of stray
26+
* top-level `::::`+ fences (plus the prose between them) in a synthetic
27+
* `::: chapter`, which the parser then tokenizes correctly. The transform is a
28+
* no-op on correctly-nested input and is idempotent (re-running on its own
29+
* output changes nothing). Top-level *depth-3* blocks (`::: cards`) are already
30+
* handled by the parser — it wraps them in an implicit chapter — and are left
31+
* untouched.
32+
*
33+
* This runs at render time (the only place CAML is parsed) so it repairs
34+
* already-stored articles as well as new ones; it pairs with the backend
35+
* prompt hardening that reduces the rate of mis-nested output at the source.
36+
*/
37+
import { parseCaml } from "@os-legal/caml";
38+
import type { CamlDocument } from "@os-legal/caml";
39+
40+
/** Header of a fence line: 3+ leading colons, then the (possibly empty) rest. */
41+
const FENCE_RE = /^(:{3,})(.*)$/;
42+
43+
/** Splits YAML frontmatter from the body exactly as the parser does, so the
44+
* normalizer never touches frontmatter content. */
45+
const FRONTMATTER_RE = /^(---[ \t]*\n[\s\S]*?\n---[ \t]*\n)([\s\S]*)$/;
46+
47+
const SYNTHETIC_CHAPTER_OPEN = "::: chapter";
48+
const SYNTHETIC_CHAPTER_CLOSE = ":::";
49+
50+
/**
51+
* Wrap stray top-level `::::`+ block fences in a synthetic `::: chapter` so the
52+
* upstream parser recognises them as blocks instead of leaking them as prose.
53+
*
54+
* Pure function, no side effects. Returns `source` unchanged when there is no
55+
* depth-4 fence to rescue or when the input is already well nested.
56+
*/
57+
export function normalizeCamlSource(source: string): string {
58+
// Fast path: a depth-4 fence is a prerequisite for the bug, so if the source
59+
// contains no `::::` anywhere there is nothing to wrap.
60+
if (!source || !source.includes("::::")) return source;
61+
62+
const fmMatch = source.match(FRONTMATTER_RE);
63+
const head = fmMatch ? fmMatch[1] : "";
64+
const body = fmMatch ? fmMatch[2] : source;
65+
66+
const lines = body.split("\n");
67+
const out: string[] = [];
68+
69+
// True while inside a real depth-3 region — a `::: chapter` or a top-level
70+
// `::: <block>`. Depth-4 fences nested inside such a region are valid and
71+
// pass through untouched.
72+
let inDepth3 = false;
73+
// True while we are adopting a run of stray top-level `::::`+ fences into a
74+
// synthetic chapter we opened.
75+
let wrapping = false;
76+
77+
const closeWrap = () => {
78+
if (wrapping) {
79+
out.push(SYNTHETIC_CHAPTER_CLOSE);
80+
wrapping = false;
81+
}
82+
};
83+
84+
for (const line of lines) {
85+
const fence = line.trim().match(FENCE_RE);
86+
87+
if (!fence) {
88+
// Non-fence line: prose either passes through or is carried inside the
89+
// synthetic wrapper we are currently building.
90+
out.push(line);
91+
continue;
92+
}
93+
94+
const colons = fence[1].length;
95+
const rest = fence[2].trim();
96+
97+
if (colons === 3) {
98+
if (rest === "") {
99+
// A bare `:::` closes an open depth-3 region; at the top level the
100+
// parser ignores it, so we only flip state when one is open.
101+
if (inDepth3) inDepth3 = false;
102+
out.push(line);
103+
continue;
104+
}
105+
// `::: <type>` opens a real depth-3 region (chapter or top-level block).
106+
// A real region supersedes any synthetic wrapper in progress.
107+
closeWrap();
108+
inDepth3 = true;
109+
out.push(line);
110+
continue;
111+
}
112+
113+
// colons >= 4: a block fence (open or close line).
114+
if (inDepth3) {
115+
// Correctly nested inside a depth-3 region — leave as-is.
116+
out.push(line);
117+
continue;
118+
}
119+
120+
// Stray top-level block fence: open a synthetic chapter to adopt it.
121+
if (!wrapping) {
122+
out.push(SYNTHETIC_CHAPTER_OPEN);
123+
wrapping = true;
124+
}
125+
out.push(line);
126+
}
127+
128+
closeWrap();
129+
130+
return head + out.join("\n");
131+
}
132+
133+
/**
134+
* Parse a CAML article, repairing stray top-level block fences first.
135+
*
136+
* Drop-in replacement for `parseCaml()` for article sources (corpus
137+
* `Readme.CAML`). Use this instead of the raw parser anywhere an article body
138+
* authored by a user or an LLM is rendered.
139+
*/
140+
export function parseCamlArticle(source: string): CamlDocument {
141+
return parseCaml(normalizeCamlSource(source));
142+
}

0 commit comments

Comments
 (0)