Skip to content

Commit f483ae4

Browse files
committed
feat: add paragraph borders doc page and spec section linking
- New doc page for w:pBdr with live previews, implementation notes, and rendering diagram for between-border groups - Spec explorer supports ?section=ID&part=N for direct section lookup - Spec references in docs link directly to the spec explorer - Table cells now render inline markdown (code, links) - Sidebar "new" badge support - Add CLAUDE.md and AGENTS.md
1 parent 540f4be commit f483ae4

7 files changed

Lines changed: 372 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

CLAUDE.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# ooxml.dev
2+
3+
The OOXML spec, explained by people who actually implemented it.
4+
5+
An interactive reference for ECMA-376 (Office Open XML) built by the [SuperDoc](https://superdoc.dev) team. Live previews are rendered with SuperDoc itself — every example on the site is a working document.
6+
7+
## Why This Exists
8+
9+
The official ECMA-376 spec is 5,000+ pages. Most of it you'll never need, and the parts you do need often omit critical rendering details that only surface when you compare against Word's actual behavior. This site fills that gap with implementation notes from building SuperDoc — a document editor that renders OOXML natively in the browser.
10+
11+
This is also how people discover SuperDoc. By sharing what we've learned, we position ourselves as the OOXML experts. Every page should reflect that authority: practical, specific, from-experience.
12+
13+
## Content Philosophy
14+
15+
**Write for implementers, not spec lawyers.** The audience is developers building document tools who need to know what the spec doesn't tell them.
16+
17+
Every doc page should answer:
18+
1. **What does the XML look like?** — Structure tree and live examples
19+
2. **What does Word actually do?** — Rendering behavior, especially where it diverges from the spec
20+
3. **What will trip you up?** — Implementation notes from real experience
21+
22+
Keep notes concise (1-2 sentences). Lead with the insight, not the backstory. Use `app: "Word"` when the behavior is Word-specific.
23+
24+
## Project Structure
25+
26+
```
27+
apps/
28+
web/ React app (Vite, React Router, Tailwind)
29+
src/data/docs.ts ← All doc pages live here (single source of truth)
30+
src/components/ UI components (Sidebar, SuperDocPreview, etc.)
31+
src/pages/ Route pages (Home, Docs, SpecExplorer, Mcp)
32+
mcp-server/ Cloudflare Worker — MCP server for AI spec search
33+
packages/
34+
shared/ Database client, embedding client, types
35+
scripts/
36+
ingest/ PDF → chunks → embeddings → database pipeline
37+
db/
38+
schema.sql PostgreSQL + pgvector schema
39+
dev/
40+
data/ Extracted/chunked/embedded spec content
41+
```
42+
43+
## Commands
44+
45+
```bash
46+
bun install # Install dependencies
47+
bun dev # Web app at http://localhost:5173
48+
bun dev:mcp # MCP server at http://localhost:8787
49+
bun run build # Production build (web)
50+
bun run typecheck # Type-check all packages
51+
```
52+
53+
## Adding a Doc Page
54+
55+
All documentation lives in `apps/web/src/data/docs.ts` as a keyed object. Each page has a `title`, optional `badge` (OOXML element), and `content` array of typed blocks.
56+
57+
### Content block types
58+
59+
| Type | Purpose |
60+
|------|---------|
61+
| `heading` | Section heading (level 2, 3, or 4) |
62+
| `paragraph` | Prose text (supports markdown links) |
63+
| `code` | Code/structure block with optional language |
64+
| `preview` | Live OOXML rendered by SuperDoc (editable XML + preview) |
65+
| `note` | Implementation note (critical / warning / info / tip) with optional `app` |
66+
| `table` | Data table with headers and rows |
67+
68+
### Steps
69+
70+
1. Add an entry to the `docs` object in `apps/web/src/data/docs.ts`
71+
2. Add a sidebar link in `apps/web/src/components/Sidebar.tsx` under the right section
72+
3. The page auto-routes to `/docs/{key}`
73+
74+
### Page structure convention
75+
76+
Follow this order (see existing pages for examples):
77+
1. Intro paragraph — what the element does, one sentence on why it matters
78+
2. **Structure** — element tree showing hierarchy and attributes
79+
3. **Examples** — live `preview` blocks, start simple, build complexity
80+
4. **Implementation Notes** — the real value; what the spec doesn't tell you
81+
5. **Schema** — reference table of elements/attributes
82+
83+
### Writing implementation notes
84+
85+
- **critical** — things that will break your implementation if you get them wrong
86+
- **warning** — non-obvious behavior that affects rendering
87+
- **info** — good to know, won't break things
88+
- **tip** — helpful shortcuts or techniques
89+
90+
Use `app: "Word"` (or `"Word, LibreOffice"`) when the behavior is application-specific. Omit `app` for universal observations.
91+
92+
## SuperDoc Preview Component
93+
94+
The `preview` block type renders XML with SuperDoc loaded from unpkg. It creates a minimal .docx in-memory (via JSZip), passes it to SuperDoc, and shows a split view: editable XML on the left, live rendering on the right.
95+
96+
The XML you provide is wrapped in a minimal `w:document > w:body` structure automatically. Just provide the body content (paragraphs, tables, etc.).
97+
98+
## MCP Server
99+
100+
Cloudflare Worker exposing three MCP tools for AI-powered spec search:
101+
102+
- `search_ecma_spec` — semantic vector search across 18,000+ spec chunks
103+
- `get_section` — fetch a specific section by ID (e.g., "17.3.1.24")
104+
- `list_parts` — browse the spec structure
105+
106+
Uses PostgreSQL with pgvector (Neon serverless in production, Docker locally).
107+
108+
## Data Pipeline
109+
110+
Ingests ECMA-376 PDFs into the vector database:
111+
112+
```
113+
PDF → extract (Python) → chunk (6KB) → embed (Voyage) → upload (PostgreSQL)
114+
```
115+
116+
Run the full pipeline: `bun scripts/ingest/pipeline.ts`
117+
118+
## Database
119+
120+
Local dev uses Docker (`docker-compose.yml`). Production uses NeonDB.
121+
122+
```bash
123+
bun run db:up # Start PostgreSQL + pgvector
124+
bun run db:down # Stop
125+
bun run db:reset # Fresh database
126+
```
127+
128+
## Deployment
129+
130+
- **Web app**: Cloudflare Pages (`wrangler pages deploy dist`)
131+
- **MCP server**: Cloudflare Workers (`wrangler deploy` from `apps/mcp-server/`)
132+
- **Database**: NeonDB (serverless PostgreSQL)

apps/web/src/components/Sidebar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const NAV_SECTIONS = [
1111
title: "WordprocessingML",
1212
items: [
1313
{ to: "/docs/paragraphs", label: "Paragraphs", path: "paragraphs" },
14+
{ to: "/docs/paragraph-borders", label: "Paragraph Borders", path: "paragraph-borders", isNew: true },
1415
{ to: "/docs/tables", label: "Tables", path: "tables" },
1516
{ to: "/docs/styles", label: "Styles", path: "styles", disabled: true },
1617
],
@@ -67,6 +68,7 @@ export function Sidebar({ onSearchClick }: SidebarProps) {
6768
label={item.label}
6869
active={currentPath === item.path}
6970
disabled={item.disabled}
71+
isNew={item.isNew}
7072
/>
7173
))}
7274
</ul>
@@ -118,12 +120,14 @@ function SidebarLink({
118120
label,
119121
active,
120122
disabled,
123+
isNew,
121124
onClick,
122125
}: {
123126
to: string;
124127
label: string;
125128
active: boolean;
126129
disabled?: boolean;
130+
isNew?: boolean;
127131
onClick?: () => void;
128132
}) {
129133
if (disabled) {
@@ -153,7 +157,16 @@ function SidebarLink({
153157
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]",
154158
)}
155159
>
156-
{label}
160+
{isNew ? (
161+
<span className="relative pr-8">
162+
{label}
163+
<span className="absolute -top-1 right-0 rounded bg-[var(--color-accent)]/15 px-1 py-0.5 text-[8px] font-medium text-[var(--color-accent)]">
164+
new
165+
</span>
166+
</span>
167+
) : (
168+
label
169+
)}
157170
</Link>
158171
</li>
159172
);

apps/web/src/data/docs.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,183 @@ export const docs: Record<string, DocPage> = {
271271
],
272272
},
273273

274+
"paragraph-borders": {
275+
title: "Paragraph Borders",
276+
description:
277+
"Borders and shading around paragraphs — side borders, between-border groups, and the space attribute.",
278+
badge: "w:pBdr",
279+
content: [
280+
{
281+
type: "paragraph",
282+
text: "Paragraph borders (`w:pBdr`) draw border lines around paragraphs and can group consecutive paragraphs into a single bordered box. The spec reads straightforward, but Word's rendering rules have several gotchas that aren't documented.",
283+
},
284+
{ type: "heading", level: 2, text: "Structure" },
285+
{
286+
type: "code",
287+
code: `w:pPr (paragraph properties)
288+
└── w:pBdr (paragraph borders)
289+
├── w:top (top border)
290+
├── w:bottom (bottom border)
291+
├── w:left (left border)
292+
├── w:right (right border)
293+
├── w:between (between border — separator within groups)
294+
└── w:bar (vertical bar border)
295+
296+
Each border element has:
297+
├── @w:val Border style (single, double, dashed, dotted, nil, none...)
298+
├── @w:sz Width in 1/8 of a point (sz="12" → 1.5pt)
299+
├── @w:space Distance from text to border, in points
300+
└── @w:color Hex color (e.g., "000000")`,
301+
},
302+
{ type: "heading", level: 2, text: "Basic Example" },
303+
{
304+
type: "preview",
305+
title: "Paragraph with all four borders",
306+
xml: `<w:p>
307+
<w:pPr>
308+
<w:pBdr>
309+
<w:top w:val="single" w:sz="12" w:space="1" w:color="000000"/>
310+
<w:left w:val="single" w:sz="12" w:space="4" w:color="000000"/>
311+
<w:bottom w:val="single" w:sz="12" w:space="1" w:color="000000"/>
312+
<w:right w:val="single" w:sz="12" w:space="4" w:color="000000"/>
313+
</w:pBdr>
314+
</w:pPr>
315+
<w:r><w:t>A paragraph with borders on all four sides.</w:t></w:r>
316+
</w:p>`,
317+
},
318+
{ type: "heading", level: 2, text: "Between Border Groups" },
319+
{
320+
type: "paragraph",
321+
text: "When consecutive paragraphs have identical border definitions AND include a `w:between` element, Word groups them into a single bordered box. The between border draws as a horizontal separator between group members.",
322+
},
323+
{
324+
type: "preview",
325+
title: "Two paragraphs grouped with a between border",
326+
xml: `<w:p>
327+
<w:pPr>
328+
<w:pBdr>
329+
<w:top w:val="single" w:sz="12" w:space="1" w:color="000000"/>
330+
<w:left w:val="single" w:sz="12" w:space="4" w:color="000000"/>
331+
<w:bottom w:val="single" w:sz="12" w:space="1" w:color="000000"/>
332+
<w:right w:val="single" w:sz="12" w:space="4" w:color="000000"/>
333+
<w:between w:val="single" w:sz="6" w:space="1" w:color="000000"/>
334+
</w:pBdr>
335+
</w:pPr>
336+
<w:r><w:t>First paragraph in the group.</w:t></w:r>
337+
</w:p>
338+
<w:p>
339+
<w:pPr>
340+
<w:pBdr>
341+
<w:top w:val="single" w:sz="12" w:space="1" w:color="000000"/>
342+
<w:left w:val="single" w:sz="12" w:space="4" w:color="000000"/>
343+
<w:bottom w:val="single" w:sz="12" w:space="1" w:color="000000"/>
344+
<w:right w:val="single" w:sz="12" w:space="4" w:color="000000"/>
345+
<w:between w:val="single" w:sz="6" w:space="1" w:color="000000"/>
346+
</w:pBdr>
347+
</w:pPr>
348+
<w:r><w:t>Second paragraph — between border separates them.</w:t></w:r>
349+
</w:p>`,
350+
},
351+
{ type: "heading", level: 2, text: "Nil/None Between — Grouping Without a Separator" },
352+
{
353+
type: "paragraph",
354+
text: 'Setting `w:between` to `val="nil"` or `val="none"` does NOT mean "don\'t group." It means "group these paragraphs but don\'t draw a separator." The result is a single continuous bordered box with no divider between paragraphs.',
355+
},
356+
{
357+
type: "preview",
358+
title: "Grouped paragraphs with no separator (nil between)",
359+
xml: `<w:p>
360+
<w:pPr>
361+
<w:pBdr>
362+
<w:top w:val="single" w:sz="12" w:space="1" w:color="000000"/>
363+
<w:left w:val="single" w:sz="12" w:space="4" w:color="000000"/>
364+
<w:bottom w:val="single" w:sz="12" w:space="1" w:color="000000"/>
365+
<w:right w:val="single" w:sz="12" w:space="4" w:color="000000"/>
366+
<w:between w:val="nil"/>
367+
</w:pBdr>
368+
</w:pPr>
369+
<w:r><w:t>First paragraph — no between separator.</w:t></w:r>
370+
</w:p>
371+
<w:p>
372+
<w:pPr>
373+
<w:pBdr>
374+
<w:top w:val="single" w:sz="12" w:space="1" w:color="000000"/>
375+
<w:left w:val="single" w:sz="12" w:space="4" w:color="000000"/>
376+
<w:bottom w:val="single" w:sz="12" w:space="1" w:color="000000"/>
377+
<w:right w:val="single" w:sz="12" w:space="4" w:color="000000"/>
378+
<w:between w:val="nil"/>
379+
</w:pBdr>
380+
</w:pPr>
381+
<w:r><w:t>Second paragraph — continuous box, no divider.</w:t></w:r>
382+
</w:p>`,
383+
},
384+
{ type: "heading", level: 2, text: "How Word Renders Groups" },
385+
{
386+
type: "paragraph",
387+
text: "The spec describes `w:between` but doesn't spell out the rendering rules. Here's what Word actually does with a group of 3 paragraphs:",
388+
},
389+
{
390+
type: "code",
391+
code: `┌─────────────────────────────┐ ← top border (from A)
392+
│ Paragraph A text │
393+
├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ ← between border
394+
│ Paragraph B text │
395+
├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ ← between border
396+
│ Paragraph C text │
397+
└─────────────────────────────┘ ← bottom border (from C)
398+
399+
- A: top + left + right + between-as-bottom
400+
- B: left + right + between-as-bottom (top suppressed)
401+
- C: left + right + bottom (top suppressed)
402+
- Left/right borders bridge paragraph spacing gaps`,
403+
},
404+
{ type: "heading", level: 2, text: "Implementation Notes" },
405+
{
406+
type: "note",
407+
noteType: "critical",
408+
title: "How between-border grouping works",
409+
text: 'Consecutive paragraphs form a group when they all have a `w:between` element AND all border properties match (top, bottom, left, right, between). Crucially, `val="nil"` or `val="none"` still triggers grouping — it means "group without a separator," not "don\'t group." If you normalize nil/none to `undefined` during parsing, you lose the grouping signal entirely.',
410+
app: "Word",
411+
},
412+
{
413+
type: "note",
414+
noteType: "warning",
415+
title: "The space attribute and unit mismatch",
416+
text: 'The `space` attribute sets the distance (in points) between a border\'s inner edge and the text. For between borders, this padding applies on both sides — above and below. Note that `sz` uses a different unit: eighths of a point (`sz="12"` = 1.5pt). Easy to mix up since they\'re on the same element.',
417+
app: "Word",
418+
},
419+
{ type: "heading", level: 2, text: "Schema" },
420+
{
421+
type: "table",
422+
headers: ["Element", "Description"],
423+
rows: [
424+
["`w:top`", "Top border"],
425+
["`w:bottom`", "Bottom border"],
426+
["`w:left`", "Left border"],
427+
["`w:right`", "Right border"],
428+
["`w:between`", "Border between grouped paragraphs"],
429+
["`w:bar`", "Vertical bar border (drawn outside the paragraph)"],
430+
],
431+
},
432+
{
433+
type: "table",
434+
headers: ["Attribute", "Type", "Description"],
435+
rows: [
436+
["`w:val`", "ST_Border", "Border style — single, double, dashed, dotted, nil, none, etc."],
437+
["`w:sz`", "integer", "Width in 1/8 of a point (e.g., 12 = 1.5pt)"],
438+
["`w:space`", "integer", "Distance from text to border inner edge, in points"],
439+
["`w:color`", "hex", "Border color (e.g., 000000, auto)"],
440+
["`w:shadow`", "boolean", "Shadow effect on the border"],
441+
["`w:frame`", "boolean", "Frame effect on the border"],
442+
],
443+
},
444+
{
445+
type: "paragraph",
446+
text: "Spec reference: ECMA-376 [§17.3.1.24 (pBdr)](/spec?section=17.3.1.24&part=1), [§17.3.1.7 (bottom border)](/spec?section=17.3.1.7&part=1), [§17.3.1.31 (shd)](/spec?section=17.3.1.31&part=1)",
447+
},
448+
],
449+
},
450+
274451
"creating-documents": {
275452
title: "Creating Documents",
276453
description: "Step-by-step guide to creating a valid OOXML document from scratch.",

apps/web/src/pages/Home.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,19 @@ export function Home() {
2626
</Link>
2727
</div>
2828

29-
{/* Spec Explorer Callout */}
29+
{/* New Content Callout */}
3030
<div className="flex items-center justify-center gap-2 text-sm">
3131
<span className="bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-[10px] font-medium px-1.5 py-0.5 rounded">
3232
NEW
3333
</span>
3434
<span className="text-[var(--color-text-secondary)]">
35-
AI-powered spec search with PDF viewer
35+
Paragraph Borders - between-border groups, nil/none semantics, and rendering gotchas
3636
</span>
3737
<Link
38-
to="/spec"
38+
to="/docs/paragraph-borders"
3939
className="text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] font-medium text-xs"
4040
>
41-
Try it →
41+
Read it →
4242
</Link>
4343
</div>
4444
</main>

0 commit comments

Comments
 (0)