diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9a3b939..070fdd9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **CAML Interactive Article System** (PR #1156): Full-stack support for corpus articles using CAML (Corpus Article Markup Language). Includes a two-pass parser (tokenizer + block parsers), typed intermediate representation, and composable renderer supporting hero sections, cards, pills, tabs, timelines, CTAs, signup blocks, corpus stats, and pullquotes. Backend adds `MarkdownParser` pipeline component (`opencontractserver/pipeline/parsers/oc_markdown_parser.py`) with `text/markdown` MIME type detection, `text_to_frontmatter` title filter on `DocumentFilter`, and centralized `TEXT_MIMETYPES` constant (`opencontractserver/constants/document_processing.py`). Frontend adds `CamlArticleEditor` modal with live preview, `CorpusArticleView` for rendered article display, and `CorpusLandingView` integration for article discovery. Comprehensive unit tests for parser and `safeHref` XSS guard (34 tests), plus Playwright component tests with `docScreenshot` captures for all block types. + +### Fixed + +- **CAML YAML parser nested key bug**: Fixed `parseYamlFrontmatter` in `frontend/src/caml/parser/tokenizer.ts` where `content` used `line.trimEnd()` instead of `line.trimEnd().trimStart()`, causing the key-value regex (`^[a-zA-Z_]...`) to fail for indented nested keys (e.g., `hero.kicker`). Nested frontmatter properties were silently dropped, producing empty objects. + +### Changed + +- **Replaced hardcoded hex colors with OS_LEGAL_COLORS tokens** in `CamlArticleEditor.tsx` and `CorpusArticleView.tsx`: All hardcoded hex values (`#e2e8f0`, `#fafbfc`, `#f8fafc`, `#64748b`, `#94a3b8`, `#ffffff`, `#fef3c7`, `#92400e`, `#475569`, `#f1f5f9`, `#cbd5e1`) replaced with semantic design tokens from `osLegalStyles.ts`. +- **Extracted `isExternalHref` helper** in `frontend/src/caml/renderer/safeHref.ts`: Deduplicated `href.startsWith("http")` checks across `CamlBlocks.tsx` and `CamlFooter.tsx` into a shared utility function. +- **Removed redundant `articleStats` useMemo** in `CorpusArticleView.tsx`: The memo mirrored its input without transformation; `stats` is now passed directly to `CamlArticle`. + ### Fixed - **Annotation rendering cleanup** (Closes #1144): Replaced hardcoded `"4px 4px 0 0"` border-radius with `ANNOTATION_BOUNDARY_RADIUS` constant in `SearchResult.tsx` and `ChatSourceResult.tsx`. Removed dead `border` prop from `SelectionInfo` interface and call sites. Used `APPROVED_RGB` constant for approved-state box-shadow in `SelectionBoundary.tsx` (matching `REJECTED_RGB` pattern). Added `TOKEN_EXPANSION_PX` constant and replaced magic `-1`/`+2` token expansion values across `SelectionTokenGroup.tsx`, `Tokens.tsx`, and `ChatSourceTokens.tsx`. Consolidated `[Previous Unreleased]` CHANGELOG section into single `[Unreleased]` block. Removed debug `console.log` statements from `DocumentKnowledgeBase.ct.tsx`, `SearchResult.tsx`, and `SelectionTokenGroup.tsx`. Moved annotation display reactive var initialization into `useEffect` in `DocumentKnowledgeBaseTestWrapper.tsx`. diff --git a/config/graphql/document_mutations.py b/config/graphql/document_mutations.py index 2ad1722bf..cbf0859cc 100644 --- a/config/graphql/document_mutations.py +++ b/config/graphql/document_mutations.py @@ -157,7 +157,13 @@ def mutate( if kind is None: if is_plaintext_content(file_bytes): - kind = "text/plain" + # Detect markdown/CAML files by extension + if filename and filename.lower().endswith( + (".caml", ".md", ".markdown") + ): + kind = "text/markdown" + else: + kind = "text/plain" else: return UploadDocument( message="Unable to determine file type", ok=False, document=None diff --git a/config/graphql/filters.py b/config/graphql/filters.py index 44dfae9f8..6e29a74b4 100644 --- a/config/graphql/filters.py +++ b/config/graphql/filters.py @@ -466,6 +466,7 @@ class Meta: fields = { "description": ["exact", "contains"], "id": ["exact"], + "title": ["exact", "contains"], } diff --git a/docs/assets/images/screenshots/auto/caml--article--full-render.png b/docs/assets/images/screenshots/auto/caml--article--full-render.png new file mode 100644 index 000000000..77dbab46c Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--article--full-render.png differ diff --git a/docs/assets/images/screenshots/auto/caml--article--minimal.png b/docs/assets/images/screenshots/auto/caml--article--minimal.png new file mode 100644 index 000000000..5e556cc47 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--article--minimal.png differ diff --git a/docs/assets/images/screenshots/auto/caml--article-view--empty-state.png b/docs/assets/images/screenshots/auto/caml--article-view--empty-state.png new file mode 100644 index 000000000..0409d2207 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--article-view--empty-state.png differ diff --git a/docs/assets/images/screenshots/auto/caml--article-view--toolbar.png b/docs/assets/images/screenshots/auto/caml--article-view--toolbar.png new file mode 100644 index 000000000..0409d2207 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--article-view--toolbar.png differ diff --git a/docs/assets/images/screenshots/auto/caml--cards--grid-render.png b/docs/assets/images/screenshots/auto/caml--cards--grid-render.png new file mode 100644 index 000000000..f5d1f4881 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--cards--grid-render.png differ diff --git a/docs/assets/images/screenshots/auto/caml--case-history--with-entries.png b/docs/assets/images/screenshots/auto/caml--case-history--with-entries.png new file mode 100644 index 000000000..2b79ca3e2 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--case-history--with-entries.png differ diff --git a/docs/assets/images/screenshots/auto/caml--chapter--dark-gradient.png b/docs/assets/images/screenshots/auto/caml--chapter--dark-gradient.png new file mode 100644 index 000000000..0056314c4 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--chapter--dark-gradient.png differ diff --git a/docs/assets/images/screenshots/auto/caml--corpus-home--article-landing.png b/docs/assets/images/screenshots/auto/caml--corpus-home--article-landing.png new file mode 100644 index 000000000..40fc87ba8 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--corpus-home--article-landing.png differ diff --git a/docs/assets/images/screenshots/auto/caml--corpus-stats--with-data.png b/docs/assets/images/screenshots/auto/caml--corpus-stats--with-data.png new file mode 100644 index 000000000..a9b294cdb Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--corpus-stats--with-data.png differ diff --git a/docs/assets/images/screenshots/auto/caml--cta--buttons.png b/docs/assets/images/screenshots/auto/caml--cta--buttons.png new file mode 100644 index 000000000..d0daa8a03 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--cta--buttons.png differ diff --git a/docs/assets/images/screenshots/auto/caml--editor--full-template.png b/docs/assets/images/screenshots/auto/caml--editor--full-template.png new file mode 100644 index 000000000..48fdae11b Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--editor--full-template.png differ diff --git a/docs/assets/images/screenshots/auto/caml--editor--live-preview.png b/docs/assets/images/screenshots/auto/caml--editor--live-preview.png new file mode 100644 index 000000000..001a03a10 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--editor--live-preview.png differ diff --git a/docs/assets/images/screenshots/auto/caml--editor--new-article.png b/docs/assets/images/screenshots/auto/caml--editor--new-article.png new file mode 100644 index 000000000..e2ca03d5d Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--editor--new-article.png differ diff --git a/docs/assets/images/screenshots/auto/caml--hero--with-stats.png b/docs/assets/images/screenshots/auto/caml--hero--with-stats.png new file mode 100644 index 000000000..f5d1f4881 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--hero--with-stats.png differ diff --git a/docs/assets/images/screenshots/auto/caml--map--categorical.png b/docs/assets/images/screenshots/auto/caml--map--categorical.png new file mode 100644 index 000000000..a5a22cdc4 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--map--categorical.png differ diff --git a/docs/assets/images/screenshots/auto/caml--pills--with-status.png b/docs/assets/images/screenshots/auto/caml--pills--with-status.png new file mode 100644 index 000000000..f5d1f4881 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--pills--with-status.png differ diff --git a/docs/assets/images/screenshots/auto/caml--prose--pullquote.png b/docs/assets/images/screenshots/auto/caml--prose--pullquote.png new file mode 100644 index 000000000..f5d1f4881 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--prose--pullquote.png differ diff --git a/docs/assets/images/screenshots/auto/caml--tabs--compliance-active.png b/docs/assets/images/screenshots/auto/caml--tabs--compliance-active.png new file mode 100644 index 000000000..c292dcf50 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--tabs--compliance-active.png differ diff --git a/docs/assets/images/screenshots/auto/caml--tabs--risk-active.png b/docs/assets/images/screenshots/auto/caml--tabs--risk-active.png new file mode 100644 index 000000000..f5d1f4881 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--tabs--risk-active.png differ diff --git a/docs/assets/images/screenshots/auto/caml--timeline--with-legend.png b/docs/assets/images/screenshots/auto/caml--timeline--with-legend.png new file mode 100644 index 000000000..279c49967 Binary files /dev/null and b/docs/assets/images/screenshots/auto/caml--timeline--with-legend.png differ diff --git a/docs/superpowers/plans/2026-03-25-caml-npm-extraction.md b/docs/superpowers/plans/2026-03-25-caml-npm-extraction.md new file mode 100644 index 000000000..a9e43e4d0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-caml-npm-extraction.md @@ -0,0 +1,1279 @@ +# CAML NPM Library Extraction — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the CAML parser and React renderer from OpenContracts into two standalone npm packages (`@os-legal/caml` and `@os-legal/caml-react`) in a yarn workspaces monorepo. + +**Architecture:** Monorepo with two packages — a zero-dependency parser and a React renderer with theme injection. The parser is a direct lift of existing code. The renderer decouples from OC's design system via a `CamlThemeProvider` and replaces the hardcoded `MarkdownMessageRenderer` with an injectable render slot. + +**Tech Stack:** TypeScript, tsup (build), vitest (test), @changesets/cli (versioning), yarn workspaces, styled-components, react-markdown + +**Spec:** `docs/superpowers/specs/2026-03-25-caml-npm-extraction-design.md` + +--- + +## File Structure + +### New repo: `os-legal-caml/` + +``` +os-legal-caml/ +├── package.json # Workspaces root +├── tsconfig.base.json # Shared TS config +├── vitest.config.ts # Shared test config +├── LICENSE # MIT +├── .changeset/ +│ └── config.json # Changesets config +├── packages/ +│ ├── caml/ # @os-legal/caml +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── tsup.config.ts +│ │ ├── src/ +│ │ │ ├── types.ts # Lifted from frontend/src/caml/parser/types.ts +│ │ │ ├── tokenizer.ts # Lifted from frontend/src/caml/parser/tokenizer.ts +│ │ │ ├── blockParsers.ts # Lifted from frontend/src/caml/parser/blockParsers.ts +│ │ │ └── index.ts # Public API barrel +│ │ └── __tests__/ +│ │ └── parseCaml.test.ts # Lifted from frontend/src/caml/parser/__tests__/ +│ └── caml-react/ # @os-legal/caml-react +│ ├── package.json +│ ├── tsconfig.json +│ ├── tsup.config.ts +│ ├── src/ +│ │ ├── theme.ts # NEW: CamlTheme, CamlStats, defaultCamlTheme, DeepPartial, deepMerge +│ │ ├── CamlThemeProvider.tsx # NEW: Theme context + styled-components ThemeProvider wrapper +│ │ ├── CamlMarkdown.tsx # NEW: Default markdown renderer +│ │ ├── CamlArticle.tsx # Lifted + modified: adds renderMarkdown/renderAnnotationEmbed props +│ │ ├── CamlHero.tsx # Lifted + modified: import path fix +│ │ ├── CamlChapter.tsx # Lifted + modified: threads renderMarkdown/renderAnnotationEmbed +│ │ ├── CamlBlocks.tsx # Lifted + modified: replaces MarkdownMessageRenderer, threads props +│ │ ├── CamlFooter.tsx # Lifted + modified: import path fix +│ │ ├── safeHref.ts # Lifted as-is +│ │ ├── styles.ts # Lifted + modified: OS_LEGAL_* → theme.caml.* (~100 replacements) +│ │ └── index.ts # Public API barrel +│ └── __tests__/ +│ └── safeHref.test.ts # Lifted from frontend/src/caml/renderer/__tests__/ +``` + +### Modified in OpenContracts (after library is published): + +``` +frontend/src/caml/ # DELETED entirely +frontend/src/components/corpuses/ + CamlArticleEditor.tsx # MODIFIED: update imports +frontend/src/components/corpuses/CorpusHome/ + CorpusArticleView.tsx # MODIFIED: update imports +frontend/package.json # MODIFIED: add @os-legal/caml, @os-legal/caml-react deps +``` + +--- + +## Task 1: Create Monorepo Scaffold + +**Files:** +- Create: `os-legal-caml/package.json` +- Create: `os-legal-caml/tsconfig.base.json` +- Create: `os-legal-caml/vitest.config.ts` +- Create: `os-legal-caml/LICENSE` +- Create: `os-legal-caml/.changeset/config.json` +- Create: `os-legal-caml/.gitignore` + +> **Note:** The new repo should be created as a sibling directory to OpenContracts (e.g., `~/Code/os-legal-caml/`). Initialize a fresh git repo there. + +- [ ] **Step 1: Create repo directory, initialize git, and set up Yarn Berry** + +> **Note:** This project uses Yarn Berry (v4+) for `workspace:*` protocol support and `workspaces foreach`. + +```bash +mkdir -p ~/Code/os-legal-caml && cd ~/Code/os-legal-caml && git init +corepack enable && yarn init -2 +yarn plugin import workspace-tools +``` + +- [ ] **Step 2: Create root `package.json`** + +```json +{ + "name": "os-legal-caml", + "private": true, + "workspaces": ["packages/*"], + "scripts": { + "build": "yarn workspaces foreach -A run build", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "clean": "rm -rf packages/*/dist" + }, + "devDependencies": { + "typescript": "^5.4.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 3: Create `tsconfig.base.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "resolveJsonModule": true + } +} +``` + +- [ ] **Step 4: Create `vitest.config.ts`** + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); +``` + +- [ ] **Step 5: Create `LICENSE` (MIT)** + +``` +MIT License + +Copyright (c) 2026 OS Legal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 6: Create `.changeset/config.json`** + +```json +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} +``` + +- [ ] **Step 7: Create `.gitignore`** + +``` +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store +``` + +- [ ] **Step 8: Install root dependencies** + +```bash +cd ~/Code/os-legal-caml && yarn install +``` + +- [ ] **Step 9: Commit scaffold** + +```bash +git add -A && git commit -m "Initialize monorepo scaffold with workspaces, vitest, and changesets" +``` + +--- + +## Task 2: Create `@os-legal/caml` Parser Package + +**Files:** +- Create: `packages/caml/package.json` +- Create: `packages/caml/tsconfig.json` +- Create: `packages/caml/tsup.config.ts` +- Copy: `packages/caml/src/types.ts` ← from `frontend/src/caml/parser/types.ts` +- Copy: `packages/caml/src/tokenizer.ts` ← from `frontend/src/caml/parser/tokenizer.ts` +- Copy: `packages/caml/src/blockParsers.ts` ← from `frontend/src/caml/parser/blockParsers.ts` +- Create: `packages/caml/src/index.ts` +- Copy: `packages/caml/__tests__/parseCaml.test.ts` ← from `frontend/src/caml/parser/__tests__/parseCaml.test.ts` + +- [ ] **Step 1: Create `packages/caml/package.json`** + +```json +{ + "name": "@os-legal/caml", + "version": "0.1.0", + "type": "module", + "description": "CAML (Corpus Article Markup Language) parser — zero-dependency markdown superset for legal articles", + "license": "MIT", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "dependencies": {}, + "devDependencies": { + "tsup": "^8.0.0" + } +} +``` + +- [ ] **Step 2: Create `packages/caml/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/caml/tsup.config.ts`** + +```typescript +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + outExtension({ format }) { + return { js: format === "esm" ? ".mjs" : ".cjs" }; + }, +}); +``` + +- [ ] **Step 4: Copy parser source files** + +Copy these files from the OpenContracts repo to the new repo: + +```bash +OC=~/Code/OpenContracts/frontend/src/caml/parser +DEST=~/Code/os-legal-caml/packages/caml + +mkdir -p $DEST/src $DEST/__tests__ + +cp $OC/types.ts $DEST/src/types.ts +cp $OC/tokenizer.ts $DEST/src/tokenizer.ts +cp $OC/blockParsers.ts $DEST/src/blockParsers.ts +cp $OC/__tests__/parseCaml.test.ts $DEST/__tests__/parseCaml.test.ts +``` + +- [ ] **Step 5: Fix the one OC-specific comment in `types.ts`** + +In `packages/caml/src/types.ts`, line 43, change: +```typescript +// Before: +content: string; // Raw markdown (rendered by MarkdownMessageRenderer) +// After: +content: string; // Raw markdown +``` + +- [ ] **Step 6: Create `packages/caml/src/index.ts`** + +```typescript +export { parseCaml } from "./tokenizer"; +export type { + // Top-level document + CamlDocument, + CamlFrontmatter, + CamlChapter, + + // Hero & Footer + CamlHero, + CamlFooter, + CamlFooterNav, + + // Block union + individual block types + CamlBlock, + CamlProse, + CamlCards, + CamlCardItem, + CamlPills, + CamlPillItem, + CamlTabs, + CamlTab, + CamlTabSection, + CamlTabSource, + CamlTimeline, + CamlTimelineLegendItem, + CamlTimelineItem, + CamlCta, + CamlCtaButton, + CamlSignup, + CamlCorpusStats, + CamlCorpusStatItem, + CamlAnnotationEmbed, +} from "./types"; +``` + +- [ ] **Step 7: Fix test import paths** + +In `packages/caml/__tests__/parseCaml.test.ts`, update both imports: + +```typescript +// Before: +import { parseCaml } from "../index"; +import type { + CamlCards, + CamlPills, + CamlTabs, + CamlTimeline, + CamlCta, + CamlSignup, + CamlCorpusStats, + CamlProse, +} from "../types"; + +// After: +import { parseCaml } from "../src/index"; +import type { + CamlCards, + CamlPills, + CamlTabs, + CamlTimeline, + CamlCta, + CamlSignup, + CamlCorpusStats, + CamlProse, +} from "../src/types"; +``` + +- [ ] **Step 8: Install dependencies and run tests** + +```bash +cd ~/Code/os-legal-caml && yarn install && yarn test +``` + +Expected: All parser tests pass (should be ~20+ tests). + +- [ ] **Step 9: Build the package** + +```bash +cd ~/Code/os-legal-caml && yarn workspace @os-legal/caml build +``` + +Expected: `packages/caml/dist/` contains `index.mjs`, `index.cjs`, `index.d.ts`. + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "Add @os-legal/caml parser package with tests" +``` + +--- + +## Task 3: Create `@os-legal/caml-react` Package Scaffold + Theme System + +**Files:** +- Create: `packages/caml-react/package.json` +- Create: `packages/caml-react/tsconfig.json` +- Create: `packages/caml-react/tsup.config.ts` +- Create: `packages/caml-react/src/theme.ts` +- Create: `packages/caml-react/src/CamlThemeProvider.tsx` + +- [ ] **Step 1: Create `packages/caml-react/package.json`** + +```json +{ + "name": "@os-legal/caml-react", + "version": "0.1.0", + "type": "module", + "description": "React renderer for CAML (Corpus Article Markup Language) articles", + "license": "MIT", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "dependencies": { + "@os-legal/caml": "workspace:*" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17", + "styled-components": ">=5", + "react-markdown": ">=8", + "remark-gfm": ">=3", + "rehype-sanitize": ">=5" + }, + "peerDependenciesMeta": { + "react-markdown": { "optional": true }, + "remark-gfm": { "optional": true }, + "rehype-sanitize": { "optional": true } + }, + "devDependencies": { + "tsup": "^8.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "styled-components": "^6.0.0", + "react-markdown": "^9.0.0", + "remark-gfm": "^4.0.0", + "rehype-sanitize": "^6.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0" + } +} +``` + +- [ ] **Step 2: Create `packages/caml-react/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/caml-react/tsup.config.ts`** + +```typescript +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + external: [ + "react", + "react-dom", + "styled-components", + "react-markdown", + "remark-gfm", + "rehype-sanitize", + ], + outExtension({ format }) { + return { js: format === "esm" ? ".mjs" : ".cjs" }; + }, +}); +``` + +- [ ] **Step 4: Create `packages/caml-react/src/theme.ts`** + +This file defines the `CamlTheme` interface, `CamlStats` type, default theme values, and internal merge utilities. + +```typescript +import type { ReactNode } from "react"; + +// --------------------------------------------------------------------------- +// CamlTheme — token interface for theming CAML articles +// --------------------------------------------------------------------------- + +export interface CamlTheme { + colors: { + accent: string; + accentHover: string; + textPrimary: string; + textSecondary: string; + textTertiary: string; + textMuted: string; + surface: string; + surfaceLight: string; + surfaceHover: string; + border: string; + heading: string; + proseText: string; + darkProse: string; + }; + typography: { + fontFamilySans: string; + fontFamilySerif: string; + }; + accentAlpha: (opacity: number) => string; +} + +// --------------------------------------------------------------------------- +// CamlStats — shared stats shape for corpus-stats blocks +// --------------------------------------------------------------------------- + +export interface CamlStats { + annotations?: number; + documents?: number; + contributors?: number; + threads?: number; +} + +// --------------------------------------------------------------------------- +// Default theme — matches OS Legal design system values +// --------------------------------------------------------------------------- + +export const defaultCamlTheme: CamlTheme = { + colors: { + accent: "#0f766e", + accentHover: "#0d6860", + textPrimary: "#1e293b", + textSecondary: "#64748b", + textTertiary: "#475569", + textMuted: "#94a3b8", + surface: "white", + surfaceLight: "#f1f5f9", + surfaceHover: "#f8fafc", + border: "#e2e8f0", + heading: "#0f172a", + proseText: "#334155", + darkProse: "#cbd5e1", + }, + typography: { + fontFamilySans: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif', + fontFamilySerif: '"Georgia", "Times New Roman", serif', + }, + accentAlpha: (opacity: number) => `rgba(15, 118, 110, ${opacity})`, +}; + +// --------------------------------------------------------------------------- +// Internal utilities +// --------------------------------------------------------------------------- + +/** Recursively make all properties optional (functions pass through as-is). */ +export type DeepPartial = { + [P in keyof T]?: T[P] extends (...args: any[]) => any + ? T[P] + : T[P] extends object + ? DeepPartial + : T[P]; +}; + +/** Shallow-merge nested objects (2 levels deep — sufficient for CamlTheme). */ +export function deepMerge( + base: CamlTheme, + overrides?: DeepPartial +): CamlTheme { + if (!overrides) return base; + return { + colors: { ...base.colors, ...overrides.colors }, + typography: { ...base.typography, ...overrides.typography }, + accentAlpha: overrides.accentAlpha ?? base.accentAlpha, + }; +} +``` + +- [ ] **Step 5: Create `packages/caml-react/src/CamlThemeProvider.tsx`** + +```tsx +import React, { createContext, useContext, type ReactNode } from "react"; +import { ThemeProvider } from "styled-components"; + +import { + type CamlTheme, + type DeepPartial, + defaultCamlTheme, + deepMerge, +} from "./theme"; + +const CamlThemeContext = createContext(defaultCamlTheme); + +export const useCamlTheme = () => useContext(CamlThemeContext); + +export function CamlThemeProvider({ + theme: overrides, + children, +}: { + theme?: DeepPartial; + children: ReactNode; +}) { + const merged = deepMerge(defaultCamlTheme, overrides); + return ( + + ({ ...outerTheme, caml: merged })}> + {children} + + + ); +} +``` + +- [ ] **Step 6: Install dependencies** + +```bash +cd ~/Code/os-legal-caml && yarn install +``` + +- [ ] **Step 7: Verify TypeScript compiles** + +```bash +cd ~/Code/os-legal-caml && yarn workspace @os-legal/caml-react tsc --noEmit +``` + +Expected: No errors. + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "Add @os-legal/caml-react scaffold with theme system" +``` + +--- + +## Task 4: Create Default Markdown Renderer + +**Files:** +- Create: `packages/caml-react/src/CamlMarkdown.tsx` + +- [ ] **Step 1: Create `packages/caml-react/src/CamlMarkdown.tsx`** + +```tsx +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeSanitize from "rehype-sanitize"; + +export function CamlMarkdown({ content }: { content: string }) { + return ( + + {content} + + ); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ~/Code/os-legal-caml && yarn workspace @os-legal/caml-react tsc --noEmit +``` + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "Add CamlMarkdown default renderer" +``` + +--- + +## Task 5: Lift and Refactor `styles.ts` + +This is the largest mechanical task — replacing ~104 direct `OS_LEGAL_COLORS`/`OS_LEGAL_TYPOGRAPHY`/`accentAlpha`/`CAML_*` references with styled-components theme reads. + +**Files:** +- Copy + modify: `packages/caml-react/src/styles.ts` ← from `frontend/src/caml/renderer/styles.ts` + +- [ ] **Step 1: Copy `styles.ts` to the new repo** + +```bash +cp ~/Code/OpenContracts/frontend/src/caml/renderer/styles.ts \ + ~/Code/os-legal-caml/packages/caml-react/src/styles.ts +``` + +- [ ] **Step 2: Remove the OC import block** + +Replace the imports at the top of the file: + +```typescript +// REMOVE these lines: +import { + OS_LEGAL_COLORS, + OS_LEGAL_TYPOGRAPHY, + accentAlpha, +} from "../../assets/configurations/osLegalStyles"; +``` + +Remove the CAML-specific color constant declarations too (they'll come from the theme): + +```typescript +// REMOVE these lines (the values are now in defaultCamlTheme): +const CAML_HEADING = "#0f172a"; +const CAML_PROSE_TEXT = "#334155"; +const CAML_DARK_PROSE = "#cbd5e1"; +``` + +- [ ] **Step 3: Add a typed theme interface for styled-components** + +Add at the top of `styles.ts` (after the `styled` import): + +```typescript +import type { CamlTheme } from "./theme"; + +// Augment styled-components default theme to include caml namespace +declare module "styled-components" { + export interface DefaultTheme { + caml: CamlTheme; + } +} +``` + +- [ ] **Step 4: Perform the mechanical replacements** + +Apply these find-and-replace patterns across the entire file: + +| Find | Replace | +|------|---------| +| `${OS_LEGAL_COLORS.accent}` | `${({ theme }) => theme.caml.colors.accent}` | +| `${OS_LEGAL_COLORS.accentHover}` | `${({ theme }) => theme.caml.colors.accentHover}` | +| `${OS_LEGAL_COLORS.textPrimary}` | `${({ theme }) => theme.caml.colors.textPrimary}` | +| `${OS_LEGAL_COLORS.textSecondary}` | `${({ theme }) => theme.caml.colors.textSecondary}` | +| `${OS_LEGAL_COLORS.textTertiary}` | `${({ theme }) => theme.caml.colors.textTertiary}` | +| `${OS_LEGAL_COLORS.textMuted}` | `${({ theme }) => theme.caml.colors.textMuted}` | +| `${OS_LEGAL_COLORS.surface}` | `${({ theme }) => theme.caml.colors.surface}` | +| `${OS_LEGAL_COLORS.surfaceLight}` | `${({ theme }) => theme.caml.colors.surfaceLight}` | +| `${OS_LEGAL_COLORS.surfaceHover}` | `${({ theme }) => theme.caml.colors.surfaceHover}` | +| `${OS_LEGAL_COLORS.border}` | `${({ theme }) => theme.caml.colors.border}` | +| `${OS_LEGAL_TYPOGRAPHY.fontFamilySans}` | `${({ theme }) => theme.caml.typography.fontFamilySans}` | +| `${OS_LEGAL_TYPOGRAPHY.fontFamilySerif}` | `${({ theme }) => theme.caml.typography.fontFamilySerif}` | +| `${accentAlpha(` | `${({ theme }) => theme.caml.accentAlpha(` | +| `${CAML_HEADING}` | `${({ theme }) => theme.caml.colors.heading}` | +| `${CAML_PROSE_TEXT}` | `${({ theme }) => theme.caml.colors.proseText}` | +| `${CAML_DARK_PROSE}` | `${({ theme }) => theme.caml.colors.darkProse}` | + +**WARNING:** The table above only works for standalone usages. There are ~20+ cases where tokens appear inside existing interpolation functions that already destructure props (e.g., `$color`, `$dark`, `$primary`, `$active`). For those, add `theme` to the existing destructuring instead of wrapping in a new function. This applies to `accentAlpha` calls too — 3 of the 4 `accentAlpha` usages are inside existing interpolation functions. Examples: + +```typescript +// Before: +color: ${({ $dark }) => + $dark ? OS_LEGAL_COLORS.textMuted : OS_LEGAL_COLORS.textSecondary}; + +// After: +color: ${({ $dark, theme }) => + $dark ? theme.caml.colors.textMuted : theme.caml.colors.textSecondary}; +``` + +Similarly for `accentAlpha` inside existing interpolations: + +```typescript +// Before: +background: ${({ $color }) => $color ? `${$color}08` : accentAlpha(0.04)}; + +// After: +background: ${({ $color, theme }) => $color ? `${$color}08` : theme.caml.accentAlpha(0.04)}; +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +```bash +cd ~/Code/os-legal-caml && yarn workspace @os-legal/caml-react tsc --noEmit +``` + +Fix any type errors. Common issues: +- Missing `theme` destructuring in prop functions that already destructure other props +- Incorrect closing parentheses after adding `theme` parameter + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "Lift styles.ts with theme-based token references" +``` + +--- + +## Task 6: Lift Renderer Components + +**Files:** +- Copy: `packages/caml-react/src/safeHref.ts` ← from `frontend/src/caml/renderer/safeHref.ts` +- Copy: `packages/caml-react/src/CamlHero.tsx` ← from `frontend/src/caml/renderer/CamlHero.tsx` +- Copy: `packages/caml-react/src/CamlFooter.tsx` ← from `frontend/src/caml/renderer/CamlFooter.tsx` +- Copy + modify: `packages/caml-react/src/CamlBlocks.tsx` ← from `frontend/src/caml/renderer/CamlBlocks.tsx` +- Copy + modify: `packages/caml-react/src/CamlChapter.tsx` ← from `frontend/src/caml/renderer/CamlChapter.tsx` +- Copy + modify: `packages/caml-react/src/CamlArticle.tsx` ← from `frontend/src/caml/renderer/CamlArticle.tsx` +- Copy: `packages/caml-react/__tests__/safeHref.test.ts` ← from `frontend/src/caml/renderer/__tests__/safeHref.test.ts` + +- [ ] **Step 1: Copy files that need no logic changes** + +```bash +SRC=~/Code/OpenContracts/frontend/src/caml/renderer +DEST=~/Code/os-legal-caml/packages/caml-react + +cp $SRC/safeHref.ts $DEST/src/safeHref.ts +cp $SRC/CamlHero.tsx $DEST/src/CamlHero.tsx +cp $SRC/CamlFooter.tsx $DEST/src/CamlFooter.tsx + +mkdir -p $DEST/__tests__ +cp $SRC/__tests__/safeHref.test.ts $DEST/__tests__/safeHref.test.ts +``` + +- [ ] **Step 2: Fix import paths in CamlHero.tsx** + +```typescript +// Before: +import type { CamlHero } from "../parser/types"; +// After: +import type { CamlHero } from "@os-legal/caml"; +``` + +Styles import stays the same (relative `./styles`). + +- [ ] **Step 3: Fix import paths in CamlFooter.tsx** + +```typescript +// Before: +import type { CamlFooter } from "../parser/types"; +// After: +import type { CamlFooter } from "@os-legal/caml"; +``` + +- [ ] **Step 4: Copy and modify CamlBlocks.tsx** + +```bash +cp $SRC/CamlBlocks.tsx $DEST/src/CamlBlocks.tsx +``` + +Changes needed in `CamlBlocks.tsx`: + +**a) Fix type imports:** +```typescript +// Before: +import type { CamlBlock, CamlCards, ... } from "../parser/types"; +// After: +import type { CamlBlock, CamlCards, ... } from "@os-legal/caml"; +``` + +**b) Replace MarkdownMessageRenderer import with CamlMarkdown + render prop:** +```typescript +// REMOVE: +import { MarkdownMessageRenderer } from "../../components/threads/MarkdownMessageRenderer"; + +// ADD: +import { CamlMarkdown } from "./CamlMarkdown"; +import type { CamlStats } from "./theme"; +import type { ReactNode } from "react"; +``` + +**c) Update `BlockRendererProps` interface:** +```typescript +interface BlockRendererProps { + block: CamlBlock; + dark?: boolean; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} +``` + +**d) Update `CamlBlockRenderer` to thread props:** +```typescript +export const CamlBlockRenderer: React.FC = ({ + block, + dark, + stats, + renderMarkdown, + renderAnnotationEmbed, +}) => { + switch (block.type) { + case "prose": + return ; + case "tabs": + return ; + case "annotation-embed": + return renderAnnotationEmbed ? ( + renderAnnotationEmbed(block.ref) + ) : ( + + Annotation embed (coming soon) + + ); + // ... other cases unchanged + case "cards": + return ; + case "pills": + return ; + case "timeline": + return ; + case "cta": + return ; + case "signup": + return ; + case "corpus-stats": + return ; + default: + return null; + } +}; +``` + +**e) Update `ProseBlock` to accept `renderMarkdown`:** +```typescript +function ProseBlock({ + block, + dark, + renderMarkdown, +}: { + block: CamlProse; + dark?: boolean; + renderMarkdown?: (content: string) => ReactNode; +}) { + const segments = splitPullquotes(block.content); + const renderMd = (content: string) => + renderMarkdown ? renderMarkdown(content) : ; + + return ( + + {segments.map((seg, i) => { + if (seg.type === "pullquote") { + return {seg.text}; + } + return {renderMd(seg.text)}; + })} + + ); +} +``` + +**f) Update `TabsBlock` to accept `renderMarkdown`:** +```typescript +function TabsBlock({ + block, + renderMarkdown, +}: { + block: CamlTabs; + renderMarkdown?: (content: string) => ReactNode; +}) { + // ... existing state logic unchanged ... + const renderMd = (content: string) => + renderMarkdown ? renderMarkdown(content) : ; + + // In the JSX, replace: + // + // With: + // {renderMd(section.content)} +} +``` + +- [ ] **Step 5: Copy and modify CamlChapter.tsx** + +```bash +cp $SRC/CamlChapter.tsx $DEST/src/CamlChapter.tsx +``` + +Changes: +```typescript +// Before: +import type { CamlChapter, CamlBlock } from "../parser/types"; +// After: +import type { CamlChapter, CamlBlock } from "@os-legal/caml"; +import type { CamlStats } from "./theme"; +import type { ReactNode } from "react"; + +export interface CamlChapterRendererProps { + chapter: CamlChapter; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} + +// Thread props to CamlBlockRenderer: + +``` + +- [ ] **Step 6: Copy and modify CamlArticle.tsx** + +```bash +cp $SRC/CamlArticle.tsx $DEST/src/CamlArticle.tsx +``` + +Changes: +```typescript +// Before: +import type { CamlDocument } from "../parser/types"; +// After: +import type { CamlDocument } from "@os-legal/caml"; +import type { CamlStats } from "./theme"; +import type { ReactNode } from "react"; + +export interface CamlArticleProps { + document: CamlDocument; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} + +// Thread props to CamlChapterRenderer: + +``` + +- [ ] **Step 7: Fix test import path in safeHref.test.ts** + +```typescript +// Before: +import { isSafeHref, isExternalHref } from "../safeHref"; +// After: +import { isSafeHref, isExternalHref } from "../src/safeHref"; +``` + +- [ ] **Step 8: Verify TypeScript compiles** + +```bash +cd ~/Code/os-legal-caml && yarn workspace @os-legal/caml-react tsc --noEmit +``` + +- [ ] **Step 9: Run tests** + +```bash +cd ~/Code/os-legal-caml && yarn test +``` + +Expected: All parser tests + safeHref tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "Lift renderer components with render slot props" +``` + +--- + +## Task 7: Create Public API Barrel + Build + +**Files:** +- Create: `packages/caml-react/src/index.ts` + +- [ ] **Step 1: Create `packages/caml-react/src/index.ts`** + +```typescript +// Components +export { CamlArticle } from "./CamlArticle"; +export type { CamlArticleProps } from "./CamlArticle"; +export { CamlThemeProvider, useCamlTheme } from "./CamlThemeProvider"; +export { CamlMarkdown } from "./CamlMarkdown"; + +// Theme +export { defaultCamlTheme } from "./theme"; +export type { CamlTheme, CamlStats } from "./theme"; + +// Types re-exported from @os-legal/caml for convenience +export type { CamlDocument } from "@os-legal/caml"; +``` + +- [ ] **Step 2: Build both packages** + +```bash +cd ~/Code/os-legal-caml && yarn build +``` + +Expected: Both `packages/caml/dist/` and `packages/caml-react/dist/` contain `.mjs`, `.cjs`, and `.d.ts` files. + +- [ ] **Step 3: Run all tests one final time** + +```bash +cd ~/Code/os-legal-caml && yarn test +``` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "Add public API barrel and verify full build" +``` + +--- + +## Task 8: Update OpenContracts to Consume Library + +> **Note:** This task happens in the OpenContracts repo, not the new library repo. During development, use `link:` protocol to point to the local library. After the library is published to npm, switch to versioned dependencies. + +**Files:** +- Modify: `frontend/package.json` +- Modify: `frontend/src/components/corpuses/CamlArticleEditor.tsx` +- Modify: `frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx` +- Delete: `frontend/src/caml/` (entire directory) + +- [ ] **Step 1: Add library dependencies to OC** + +In `frontend/package.json`, add: + +```json +{ + "dependencies": { + "@os-legal/caml": "link:../../os-legal-caml/packages/caml", + "@os-legal/caml-react": "link:../../os-legal-caml/packages/caml-react" + } +} +``` + +Then: +```bash +cd ~/Code/OpenContracts/frontend && yarn install +``` + +- [ ] **Step 2: Update `CamlArticleEditor.tsx`** + +Replace the CAML import: + +```typescript +// Before (line 28): +import { parseCaml, CamlArticle } from "../../caml"; + +// After: +import { parseCaml } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +import { MarkdownMessageRenderer } from "../threads/MarkdownMessageRenderer"; +``` + +Wrap the `` usage in `CamlThemeProvider` and pass `renderMarkdown` (line 396): + +```tsx +// Before: +{parsedDocument && } + +// After: +{parsedDocument && ( + + } + /> + +)} +``` + +- [ ] **Step 3: Update `CorpusArticleView.tsx`** + +Replace the CAML imports: + +```typescript +// Before (lines 21-22): +import { parseCaml, CamlArticle } from "../../../caml"; +import type { CamlDocument } from "../../../caml"; + +// After: +import { parseCaml } from "@os-legal/caml"; +import type { CamlDocument } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +import { MarkdownMessageRenderer } from "../../threads/MarkdownMessageRenderer"; +``` + +Wrap the `` usage and pass `renderMarkdown` (line 258): + +```tsx +// Before: + + +// After: + + } + /> + +``` + +- [ ] **Step 4: Delete the `frontend/src/caml/` directory** + +```bash +rm -rf ~/Code/OpenContracts/frontend/src/caml +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +```bash +cd ~/Code/OpenContracts/frontend && yarn tsc --noEmit +``` + +Fix any remaining import issues. Common things to check: +- No remaining imports from `../../caml` or `../../../caml` +- The `CamlArticle` component props still match (now requires wrapping in `CamlThemeProvider`) + +- [ ] **Step 6: Verify the frontend builds** + +```bash +cd ~/Code/OpenContracts/frontend && yarn build +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 7: Run pre-commit hooks** + +```bash +cd ~/Code/OpenContracts && pre-commit run --all-files +``` + +- [ ] **Step 8: Commit** + +```bash +cd ~/Code/OpenContracts +git add frontend/src/components/corpuses/CamlArticleEditor.tsx \ + frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx \ + frontend/package.json frontend/yarn.lock +git rm -r frontend/src/caml +git commit -m "Switch to @os-legal/caml and @os-legal/caml-react packages" +``` + +--- + +## Task 9: Final Verification + +- [ ] **Step 1: Run OC frontend unit tests** + +```bash +cd ~/Code/OpenContracts/frontend && yarn test:unit +``` + +Expected: No CAML-related test failures (the tests now live in the library repo). + +- [ ] **Step 2: Run library tests** + +```bash +cd ~/Code/os-legal-caml && yarn test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Verify library build outputs are clean** + +```bash +cd ~/Code/os-legal-caml && yarn build && ls -la packages/caml/dist/ && ls -la packages/caml-react/dist/ +``` + +Expected: Each dist/ has `index.mjs`, `index.cjs`, `index.d.ts` (plus `.d.mts`). diff --git a/docs/superpowers/plans/2026-03-26-caml-npm-migration.md b/docs/superpowers/plans/2026-03-26-caml-npm-migration.md new file mode 100644 index 000000000..6f167fbcd --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-caml-npm-migration.md @@ -0,0 +1,620 @@ +# CAML NPM Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Switch from local `link:` CAML packages to published npm versions, wire up corpus stats, update editor template with new block types (map, case-history), fix broken test imports, and add screenshot tests. + +**Architecture:** The `@os-legal/caml` (parser) and `@os-legal/caml-react` (renderer) packages are already integrated via `link:` protocol. This plan switches to npm `^0.0.1`, fixes test wrappers that still import from deleted in-tree paths, threads stats data through to `CamlArticle`, updates the editor template, and adds Playwright screenshot tests for new block types. + +**Tech Stack:** yarn, @os-legal/caml, @os-legal/caml-react, Playwright CT, docScreenshot utility + +--- + +### Task 1: Switch package.json from link: to npm versions + +**Files:** +- Modify: `frontend/package.json:14-15,129` + +- [ ] **Step 1: Update dependencies** + +In `frontend/package.json`, change lines 14-15 from: +```json +"@os-legal/caml": "link:../../os-legal-caml/packages/caml", +"@os-legal/caml-react": "link:../../os-legal-caml/packages/caml-react", +``` +to: +```json +"@os-legal/caml": "^0.0.1", +"@os-legal/caml-react": "^0.0.1", +``` + +- [ ] **Step 2: Remove resolutions override** + +In `frontend/package.json`, remove line 129: +```json +"@os-legal/caml": "link:../../os-legal-caml/packages/caml", +``` +from the `"resolutions"` block. Keep the other resolutions entries intact. + +- [ ] **Step 3: Install dependencies** + +Run: +```bash +cd frontend && yarn install +``` +Expected: Clean install, lockfile updated with npm registry versions. + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: +```bash +cd frontend && npx tsc --noEmit +``` +Expected: No errors. If there are type mismatches between the local dev version and the published version, fix them before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/package.json frontend/yarn.lock +git commit -m "Switch @os-legal/caml packages from link: to npm ^0.0.1" +``` + +--- + +### Task 2: Fix broken test wrapper imports + +The test wrappers still import from deleted in-tree paths (`../src/caml/renderer`, `../src/caml/parser/types`). Fix them to import from the npm packages. + +**Files:** +- Modify: `frontend/tests/CamlArticleTestWrapper.tsx:9-10` +- Modify: `frontend/tests/CamlArticle.ct.tsx:21` + +- [ ] **Step 1: Fix CamlArticleTestWrapper imports** + +In `frontend/tests/CamlArticleTestWrapper.tsx`, change lines 9-10 from: +```typescript +import { CamlArticle } from "../src/caml/renderer"; +import type { CamlDocument } from "../src/caml/parser/types"; +``` +to: +```typescript +import type { CamlDocument } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +``` + +- [ ] **Step 2: Add CamlThemeProvider to test wrapper render** + +In `frontend/tests/CamlArticleTestWrapper.tsx`, the render function (lines 206-215) currently renders `` without a theme provider. Update it to match production usage: + +Change: +```tsx + return ( + +
+ +
+
+ ); +``` +to: +```tsx + return ( + +
+ + + +
+
+ ); +``` + +- [ ] **Step 3: Fix CamlArticle.ct.tsx import** + +In `frontend/tests/CamlArticle.ct.tsx`, change line 21 from: +```typescript +import type { CamlDocument } from "../src/caml/parser/types"; +``` +to: +```typescript +import type { CamlDocument } from "@os-legal/caml"; +``` + +- [ ] **Step 4: Verify existing tests pass** + +Run: +```bash +cd frontend && yarn test:ct --reporter=list -g "CamlArticle" +``` +Expected: All existing CamlArticle tests pass (hero, cards, pills, tabs, timeline, CTA, dark theme, pullquote, empty doc, corpus stats). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/tests/CamlArticleTestWrapper.tsx frontend/tests/CamlArticle.ct.tsx +git commit -m "Fix CAML test imports to use @os-legal/caml npm packages" +``` + +--- + +### Task 3: Wire up corpus stats to CorpusArticleView + +Currently `CorpusArticleView` accepts a `stats` prop but `CorpusHome` never passes it. The stats data is already available in `Corpuses.tsx` and passed to `CorpusHome`. + +**Files:** +- Modify: `frontend/src/components/corpuses/CorpusHome.tsx:16-28,57,130-137` + +- [ ] **Step 1: Add stats fields to CorpusHomeProps** + +In `frontend/src/components/corpuses/CorpusHome.tsx`, the `stats` type (lines 23-28) currently has: +```typescript + stats: { + totalDocs: number; + totalAnnotations: number; + totalAnalyses: number; + totalExtracts: number; + }; +``` + +Add `totalThreads`: +```typescript + stats: { + totalDocs: number; + totalAnnotations: number; + totalAnalyses: number; + totalExtracts: number; + totalThreads: number; + }; +``` + +- [ ] **Step 2: Destructure stats in CorpusHome component** + +In `frontend/src/components/corpuses/CorpusHome.tsx`, add `stats` to the destructured props (line 57 area): + +Change: +```typescript +export const CorpusHome: React.FC = ({ + corpus, + onEditDescription, + onEditArticle, + chatQuery = "", +``` +to: +```typescript +export const CorpusHome: React.FC = ({ + corpus, + onEditDescription, + onEditArticle, + stats, + chatQuery = "", +``` + +- [ ] **Step 3: Pass stats to CorpusArticleView** + +In `frontend/src/components/corpuses/CorpusHome.tsx`, the article view render (lines 130-137) currently doesn't pass stats: +```tsx + +``` + +Change to: +```tsx + +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: +```bash +cd frontend && npx tsc --noEmit +``` +Expected: No type errors. The `CorpusHomeProps.stats` shape already receives all fields from `Corpuses.tsx`'s `GET_CORPUS_STATS` query result, which includes `totalThreads`. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/corpuses/CorpusHome.tsx +git commit -m "Wire corpus stats through to CAML article renderer" +``` + +--- + +### Task 4: Update CAML editor template with new block types + +Add map and case-history block examples to the `CAML_TEMPLATE` constant in `CamlArticleEditor.tsx` so users see the full range of available blocks. + +**Files:** +- Modify: `frontend/src/components/corpuses/CamlArticleEditor.tsx:193-231` + +- [ ] **Step 1: Replace CAML_TEMPLATE** + +In `frontend/src/components/corpuses/CamlArticleEditor.tsx`, replace the `CAML_TEMPLATE` constant (lines 193-231) with: + +```typescript +const CAML_TEMPLATE = `--- +version: "1.0" + +hero: + kicker: "Your organization · Interactive analysis" + title: + - "Your article" + - "{title here}" + subtitle: > + Write a compelling subtitle that describes what this + article is about and why readers should care. + stats: + - "Documents analyzed" + - "Key findings" +--- + +::: chapter {#introduction} +>! Chapter 1 +## Getting started + +Write your article content here using CAML syntax. +You can use **bold**, *italic*, and [links](https://example.com). + +>>> "Use triple blockquotes for pullquotes that stand out." + +:::: cards {columns: 2} + +- **Key Finding 1** | #0f766e + Describe the first key finding here. + ~ Source: Document A + +- **Key Finding 2** | #c4573a + Describe the second key finding here. + ~ Source: Document B + +:::: + +::: + +::: chapter {#case-tracker} +>! Chapter 2 +## Case History + +:::: case-history +title: Example Case v. Sample Corp +docket: No. 24-cv-01234 (S.D.N.Y.) +status: Pending + +- District Court | S.D.N.Y. | 2024-03-15 | Motion to Dismiss | Denied + Court found sufficient facts to proceed. + +- Court of Appeals | 2nd Circuit | 2025-01-20 | Appeal | Pending + Oral arguments scheduled. + +:::: + +::: + +::: chapter {#jurisdiction} +>! Chapter 3 +## Jurisdiction Map + +:::: map {type: us} +legend: +- Compliant | #0f766e +- Pending | #f59e0b +- Non-compliant | #dc2626 + +- CA | Compliant +- NY | Compliant +- TX | Pending +- FL | Non-compliant +- IL | Compliant + +:::: + +::: +`; +``` + +- [ ] **Step 2: Verify editor renders the new template** + +Run: +```bash +cd frontend && yarn test:ct --reporter=list -g "CamlArticleEditor" +``` +Expected: Existing tests still pass. The "new article" test should find `hero:` and `version:` in the textarea. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/corpuses/CamlArticleEditor.tsx +git commit -m "Update CAML editor template with case-history and map blocks" +``` + +--- + +### Task 5: Add map and case-history blocks to test fixture and screenshot tests + +Add new block types to `SAMPLE_CAML_DOCUMENT` and write Playwright tests with `docScreenshot` calls. + +**Files:** +- Modify: `frontend/tests/CamlArticleTestWrapper.tsx:15-195` (add blocks to fixture) +- Modify: `frontend/tests/CamlArticle.ct.tsx` (add new test describes) + +- [ ] **Step 1: Add map block to SAMPLE_CAML_DOCUMENT** + +In `frontend/tests/CamlArticleTestWrapper.tsx`, add a new chapter to the `chapters` array in `SAMPLE_CAML_DOCUMENT` (after the timeline chapter, before the closing `]`). Insert before the final `],` on line 194: + +```typescript + { + id: "jurisdiction", + kicker: "Chapter 4", + title: "Jurisdiction Map", + blocks: [ + { + type: "map", + mapType: "us", + mode: "categorical", + legend: [ + { label: "Compliant", color: "#0f766e" }, + { label: "Pending", color: "#f59e0b" }, + { label: "Non-compliant", color: "#dc2626" }, + ], + states: [ + { code: "CA", status: "Compliant" }, + { code: "NY", status: "Compliant", count: 247 }, + { code: "TX", status: "Pending", count: 56 }, + { code: "FL", status: "Non-compliant" }, + { code: "IL", status: "Compliant" }, + { code: "OH", status: "Pending" }, + ], + }, + ], + }, + { + id: "case-tracker", + kicker: "Chapter 5", + title: "Case Tracker", + blocks: [ + { + type: "case-history", + title: "SEC v. Meridian Capital Partners LLC", + docket: "No. 22-cv-04817 (S.D.N.Y.)", + status: "Affirmed", + entries: [ + { + courtLevel: "District Court", + courtName: "S.D.N.Y.", + date: "2022-06-10", + action: "Motion for TRO", + outcome: "Granted", + detail: "Court issued TRO freezing defendant assets.", + }, + { + courtLevel: "Court of Appeals", + courtName: "2nd Circuit", + date: "2023-11-08", + action: "Appeal", + outcome: "Affirmed", + }, + { + courtLevel: "Supreme Court", + courtName: "SCOTUS", + date: "2024-03-25", + action: "Certiorari", + outcome: "Cert Denied", + }, + ], + }, + ], + }, +``` + +- [ ] **Step 2: Add map block test with screenshot** + +In `frontend/tests/CamlArticle.ct.tsx`, add the following test describe after the "Corpus Stats Block" describe: + +```typescript +test.describe("CamlArticle - Map Block", () => { + test("should render US map with categorical legend and state tiles", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to map chapter + await page.getByText("Jurisdiction Map").scrollIntoViewIfNeeded(); + + // Legend should render + await expect(page.getByText("Compliant").first()).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByText("Pending").first()).toBeVisible(); + await expect(page.getByText("Non-compliant")).toBeVisible(); + + // State tiles should render (check for state codes in tiles) + await expect(page.getByText("CA").first()).toBeVisible(); + await expect(page.getByText("NY").first()).toBeVisible(); + await expect(page.getByText("TX").first()).toBeVisible(); + + await docScreenshot(page, "caml--map--categorical"); + + await component.unmount(); + }); +}); +``` + +- [ ] **Step 3: Add case-history block test with screenshot** + +In `frontend/tests/CamlArticle.ct.tsx`, add after the map test: + +```typescript +test.describe("CamlArticle - Case History Block", () => { + test("should render case history with entries and outcome badges", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to case history chapter + await page.getByText("Case Tracker").scrollIntoViewIfNeeded(); + + // Case title and docket + await expect( + page.getByText("SEC v. Meridian Capital Partners LLC") + ).toBeVisible({ timeout: 5000 }); + await expect( + page.getByText("No. 22-cv-04817 (S.D.N.Y.)") + ).toBeVisible(); + + // Status badge + await expect(page.getByText("Affirmed").first()).toBeVisible(); + + // Court entries + await expect(page.getByText("District Court").first()).toBeVisible(); + await expect(page.getByText("Motion for TRO")).toBeVisible(); + await expect(page.getByText("Granted").first()).toBeVisible(); + + // Later entries + await expect(page.getByText("Court of Appeals").first()).toBeVisible(); + await expect(page.getByText("Cert Denied")).toBeVisible(); + + await docScreenshot(page, "caml--case-history--with-entries"); + + await component.unmount(); + }); +}); +``` + +- [ ] **Step 4: Run all CAML tests** + +Run: +```bash +cd frontend && yarn test:ct --reporter=list -g "CamlArticle" +``` +Expected: All tests pass including the new map and case-history tests. Screenshots saved to `docs/assets/images/screenshots/auto/`. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/tests/CamlArticleTestWrapper.tsx frontend/tests/CamlArticle.ct.tsx docs/assets/images/screenshots/auto/ +git commit -m "Add map and case-history blocks to CAML test fixture with screenshots" +``` + +--- + +### Task 6: Add editor screenshot test with new template + +Update the editor screenshot test to capture the updated template that includes map and case-history blocks. + +**Files:** +- Modify: `frontend/tests/CamlArticleEditor.ct.tsx` + +- [ ] **Step 1: Add test for new template blocks in editor preview** + +In `frontend/tests/CamlArticleEditor.ct.tsx`, add a new test describe after the existing "Close Behavior" describe: + +```typescript +test.describe("CamlArticleEditor - New Block Types in Template", () => { + test("should render map and case-history blocks in preview from template", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Wait for editor to load + await expect(page.getByText("Create Article").first()).toBeVisible({ + timeout: 10000, + }); + + // The textarea should contain the new block types + const textarea = page.locator("textarea"); + const value = await textarea.inputValue(); + expect(value).toContain("case-history"); + expect(value).toContain("map {type: us}"); + + // Preview pane should render these blocks + // Case history title + await expect( + page.getByText("Example Case v. Sample Corp") + ).toBeVisible({ timeout: 5000 }); + + await docScreenshot(page, "caml--editor--full-template", { + fullPage: true, + }); + + await component.unmount(); + }); +}); +``` + +- [ ] **Step 2: Run editor tests** + +Run: +```bash +cd frontend && yarn test:ct --reporter=list -g "CamlArticleEditor" +``` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/tests/CamlArticleEditor.ct.tsx docs/assets/images/screenshots/auto/ +git commit -m "Add editor screenshot test for map and case-history template blocks" +``` + +--- + +### Task 7: Final verification and pre-commit + +- [ ] **Step 1: Run TypeScript compilation** + +```bash +cd frontend && npx tsc --noEmit +``` +Expected: No errors. + +- [ ] **Step 2: Run linting** + +```bash +cd frontend && yarn lint +``` +Expected: No errors. + +- [ ] **Step 3: Run pre-commit hooks** + +```bash +pre-commit run --all-files +``` +Expected: All hooks pass. + +- [ ] **Step 4: Run full CAML test suite** + +```bash +cd frontend && yarn test:ct --reporter=list -g "CamlArticle|CorpusArticleView" +``` +Expected: All tests pass, all screenshots generated. + +- [ ] **Step 5: Verify screenshots exist** + +```bash +ls -la docs/assets/images/screenshots/auto/caml--* +``` +Expected: Screenshots for all CAML test scenarios including new `caml--map--categorical.png` and `caml--case-history--with-entries.png`. diff --git a/docs/superpowers/specs/2026-03-24-caml-v2-spec.md b/docs/superpowers/specs/2026-03-24-caml-v2-spec.md new file mode 100644 index 000000000..a1252ac9b --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-caml-v2-spec.md @@ -0,0 +1,234 @@ +# CAML v2 Specification + +**Status**: Draft +**Date**: 2026-03-24 +**Depends on**: CAML v1 (PR #1156) + +## Overview + +CAML v2 extends the Corpus Article Markup Language with AI-powered citation, live data binding, notification features, and richer authoring tools. These features were intentionally deferred from v1 to ship the core parser/renderer quickly. + +## v1 Recap (Shipped) + +| Feature | Status | +|---------|--------| +| CAML parser (`parseCaml()`) | Shipped | +| Block types: prose, cards, pills, tabs, timeline, CTA | Shipped | +| Hero, chapters (dark/light themes), footer | Shipped | +| `?view=article` route in CorpusHome | Shipped | +| Split-pane editor with live preview | Shipped | +| Readme.CAML document convention | Shipped | +| `text/markdown` pipeline bypass (no NLP) | Shipped | +| `title` filter on DocumentFilter | Shipped | + +## v2 Features + +### 1. `{{cite-me}}` / `{{cite-all}}` — AI Citation System + +**Problem**: Authors want to reference specific annotations and documents from the corpus in their articles, but currently must manually construct link URLs. + +**Design**: + +```caml +::: chapter {#findings} +## Key Findings + +The force majeure clauses were updated across all agreements. {{cite-me}} + +Multiple jurisdictions require different notice periods. {{cite-all}} +::: +``` + +- `{{cite-me}}` inserts a single best-match annotation citation for the surrounding sentence +- `{{cite-all}}` inserts all matching annotation citations +- Citations render as inline chips that link to the annotation in the document viewer + +**Implementation**: + +1. **Parser**: Tokenize `{{cite-me}}` and `{{cite-all}}` as inline directives within prose blocks +2. **Backend**: New GraphQL query `findCitationsForText(corpusId, text, mode)` that uses vector similarity search on the corpus embeddings to find matching annotations +3. **Renderer**: New `CamlCitation` component that renders as a hover-expandable chip showing annotation preview, document title, and deep link +4. **Editor**: "Insert Citation" button that triggers a search modal — user types a query, sees matching annotations, clicks to insert + +**Files to modify**: +- `frontend/src/caml/parser/types.ts` — Add `CamlCitationInline` type +- `frontend/src/caml/parser/blockParsers.ts` — Parse `{{cite-me}}` within prose +- `frontend/src/caml/renderer/CamlBlocks.tsx` — Render citation chips +- `config/graphql/queries.py` — Add `findCitationsForText` resolver +- `opencontractserver/annotations/` — Vector similarity search utility + +### 2. `:::annotation-embed` — Inline Annotation Rendering + +**Problem**: Authors want to embed a specific annotation's content directly in the article, with live rendering of the annotation's text and label. + +**Design**: + +```caml +::: annotation-embed {ref: @annotation:a7f2} +::: +``` + +Renders the annotation's raw text in a styled card with: +- Annotation label and color +- Source document title and page number +- Click-to-navigate deep link to the annotation in the document viewer + +**Implementation**: + +1. **Parser**: Already parsed in v1 (returns `CamlAnnotationEmbed` type) +2. **Backend**: Annotation query by short ID (`a7f2` prefix of relay ID) +3. **Renderer**: New `AnnotationEmbedCard` component +4. **Permissions**: Must respect the reader's permissions — if they can't see the annotation's document, show a "restricted" placeholder + +**Files to modify**: +- `frontend/src/caml/renderer/CamlBlocks.tsx` — Replace placeholder with real embed +- `config/graphql/queries.py` — Add `annotationByShortId` resolver + +### 3. `:::corpus-stats` — Live Data Binding + +**Problem**: The `corpus-stats` block currently receives data via React props, requiring the parent component to fetch and pass stats. This is fragile and doesn't support custom metrics. + +**Design**: + +```caml +::: corpus-stats +- documents | Documents Analyzed +- annotations | Total Annotations +- contributors | Active Contributors +- threads | Discussion Threads +- avg_annotations_per_doc | Avg. Annotations/Doc +::: +``` + +**Implementation**: + +1. **Backend**: New GraphQL query `corpusStats(corpusId)` that returns a `GenericScalar` with computed metrics +2. **Renderer**: `CorpusStatsBlock` fetches stats via `useQuery` instead of props +3. **Custom metrics**: Support computed fields like `avg_annotations_per_doc` via backend aggregation + +**Files to modify**: +- `config/graphql/queries.py` — Add `corpusStats` resolver +- `opencontractserver/corpuses/models.py` — Add stats computation method +- `frontend/src/caml/renderer/CamlBlocks.tsx` — Self-fetching stats block + +### 4. `:::signup` — Notification Backend + +**Problem**: The signup block renders a form UI but has no backend to handle submissions. + +**Design**: + +The signup block should create a "corpus subscription" — users who sign up get notified when: +- New documents are added to the corpus +- New annotations are created +- The article itself is updated + +**Implementation**: + +1. **Model**: `CorpusSubscription(user, corpus, notify_on_documents, notify_on_annotations, notify_on_article)` +2. **Mutation**: `subscribeToCorpus(corpusId)` / `unsubscribeFromCorpus(corpusId)` +3. **Signals**: Trigger notifications via existing `Notification` model when subscribed events occur +4. **Renderer**: Signup block calls the mutation on button click, shows "Subscribed" state + +**Files to modify**: +- `opencontractserver/notifications/models.py` — Add `CorpusSubscription` model +- `opencontractserver/notifications/signals.py` — Add subscription-triggered notifications +- `config/graphql/notification_mutations.py` — Add subscribe/unsubscribe mutations +- `frontend/src/caml/renderer/CamlBlocks.tsx` — Wire up signup button + +### 5. Editor Enhancements + +#### 5a. Block Palette / Toolbar + +Add a toolbar above the editor textarea with buttons for each block type: +- Click "Cards" → inserts `::: cards` template at cursor +- Click "Tabs" → inserts `:::: tab` template +- Click "Timeline" → inserts `::: timeline` template +- etc. + +#### 5b. Drag-and-Drop Block Reordering + +In preview mode, allow dragging chapter blocks to reorder them. The editor source updates to match. + +#### 5c. Image Upload Support + +Allow `![alt](upload:image.png)` syntax that uploads images to Django media storage and replaces the path with the served URL. + +**Implementation**: +- New `UploadArticleImage` mutation +- Editor paste handler that auto-uploads clipboard images +- Preview renders `` tags for uploaded images + +### 6. VS Code Extension / CLI Tooling + +#### 6a. `.caml` Syntax Highlighting + +TextMate grammar for VS Code that provides: +- YAML frontmatter highlighting +- `::: block` fence coloring by type +- `{{cite-me}}` inline directive highlighting +- `§ source` chip highlighting + +#### 6b. `caml preview` CLI Command + +```bash +npx opencontracts-caml preview article.caml +``` + +Opens a local browser preview with hot-reload, using the same renderer components. + +#### 6c. `caml lint` CLI Command + +```bash +npx opencontracts-caml lint article.caml +``` + +Validates CAML syntax, checks for: +- Unclosed fences +- Unknown block types +- Invalid frontmatter YAML +- Missing required frontmatter fields + +## Backwards Compatibility + +All v2 features are additive. v1 articles render unchanged: +- `{{cite-me}}` is only parsed when present +- `:::annotation-embed` already has a placeholder in v1 +- `:::corpus-stats` falls back to prop-based data if query unavailable +- `:::signup` renders the form UI regardless of backend + +## Priority Order + +1. **`:::corpus-stats` live binding** — Small, high-value, no new models +2. **`:::annotation-embed`** — Medium effort, demonstrates corpus-article integration +3. **`{{cite-me}}` / `{{cite-all}}`** — High effort, highest value, requires vector search +4. **`:::signup` backend** — Medium effort, requires new model + migration +5. **Editor enhancements** — Incremental improvements, independent of each other +6. **VS Code / CLI** — Nice-to-have, independent of main app + +## File Index + +### Existing v1 Files (to modify) + +| File | Purpose | +|------|---------| +| `frontend/src/caml/parser/types.ts` | CAML JSON IR types | +| `frontend/src/caml/parser/tokenizer.ts` | CAML parser (frontmatter + fence tokenization) | +| `frontend/src/caml/parser/blockParsers.ts` | Type-specific block parsing | +| `frontend/src/caml/renderer/CamlBlocks.tsx` | Block renderer components | +| `frontend/src/caml/renderer/CamlArticle.tsx` | Top-level article renderer | +| `frontend/src/caml/renderer/styles.ts` | Styled components | +| `frontend/src/components/corpuses/CamlArticleEditor.tsx` | Split-pane editor | +| `frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx` | Article view | +| `opencontractserver/pipeline/parsers/oc_markdown_parser.py` | Backend no-op parser | +| `config/graphql/filters.py` | Document title filter | + +### New Files (v2) + +| File | Purpose | +|------|---------| +| `frontend/src/caml/renderer/CamlCitation.tsx` | Citation chip component | +| `frontend/src/caml/renderer/AnnotationEmbedCard.tsx` | Annotation embed renderer | +| `opencontractserver/notifications/models.py` | CorpusSubscription model (extend) | +| `config/graphql/queries.py` | `corpusStats`, `findCitationsForText` resolvers | +| `tools/caml-vscode/` | VS Code extension (separate package) | +| `tools/caml-cli/` | CLI preview/lint tool (separate package) | diff --git a/docs/superpowers/specs/2026-03-25-caml-npm-extraction-design.md b/docs/superpowers/specs/2026-03-25-caml-npm-extraction-design.md new file mode 100644 index 000000000..d5e5740bc --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-caml-npm-extraction-design.md @@ -0,0 +1,484 @@ +# CAML NPM Library Extraction + +Extract the CAML (Corpus Article Markup Language) parser and React renderer from OpenContracts into two standalone npm packages under the `@os-legal` scope. + +## Motivation + +CAML is a human-readable markdown superset for rendering legal articles and knowledge bases. It currently lives inside the OpenContracts frontend. Extracting it enables: + +- Reuse across projects without pulling in OpenContracts +- CLI tooling (linters, validators, VS Code extensions) via the framework-agnostic parser +- A standalone standard for legal article markup + +## Packages + +| Package | Purpose | Dependencies | +|---------|---------|--------------| +| `@os-legal/caml` | Parser, types, IR definition | Zero | +| `@os-legal/caml-react` | React renderer, theme system, default markdown | Peer: `react`, `react-dom`, `styled-components`. Optional peer: `react-markdown`, `remark-gfm`, `rehype-sanitize` | + +`@os-legal/caml-react` depends on `@os-legal/caml` as a runtime dependency. + +## Repo Structure + +Monorepo with yarn workspaces, single Git repo (`os-legal-caml`): + +``` +os-legal-caml/ +├── packages/ +│ ├── caml/ → @os-legal/caml +│ │ ├── src/ +│ │ │ ├── types.ts # IR types +│ │ │ ├── tokenizer.ts # Two-pass parser (frontmatter + fence tokenization) +│ │ │ ├── blockParsers.ts # Block-specific parsing (cards, pills, tabs, etc.) +│ │ │ └── index.ts # Public API: parseCaml + all type re-exports +│ │ ├── __tests__/ +│ │ │ └── parseCaml.test.ts # Existing parser tests (lifted as-is) +│ │ ├── package.json +│ │ └── tsconfig.json +│ └── caml-react/ → @os-legal/caml-react +│ ├── src/ +│ │ ├── CamlArticle.tsx # Top-level renderer +│ │ ├── CamlHero.tsx # Hero section +│ │ ├── CamlChapter.tsx # Chapter section with theme/gradient support +│ │ ├── CamlBlocks.tsx # Block type renderers (prose, cards, pills, tabs, etc.) +│ │ ├── CamlFooter.tsx # Footer section +│ │ ├── CamlMarkdown.tsx # NEW: default markdown renderer +│ │ ├── CamlThemeProvider.tsx # NEW: theme context + provider +│ │ ├── theme.ts # NEW: CamlTheme interface + defaultCamlTheme + DeepPartial + deepMerge +│ │ ├── styles.ts # Styled components (refactored to read from theme) +│ │ ├── safeHref.ts # URL safety guard +│ │ └── index.ts # Public API +│ ├── __tests__/ +│ │ └── safeHref.test.ts # URL safety tests (lifted as-is) +│ ├── package.json +│ └── tsconfig.json +├── package.json # Workspaces root +├── tsconfig.base.json # Shared compiler options +├── vitest.config.ts # Shared test config +├── LICENSE # MIT license +└── .changeset/ # Changeset versioning config +``` + +## Build & Publish + +- **Build**: `tsup` per package — ESM + CJS dual output with `.d.ts` generation. +- **Versioning**: `@changesets/cli` — changeset per PR, `changeset version` bumps packages and cross-dependencies atomically. +- **CI**: GitHub Actions — lint, test, build on PRs; publish to npm on release tags. + +### `@os-legal/caml` package.json (key fields) + +```json +{ + "name": "@os-legal/caml", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "dependencies": {}, + "devDependencies": { + "tsup": "...", + "typescript": "...", + "vitest": "..." + } +} +``` + +tsup config: `format: ["esm", "cjs"]` to produce `.mjs` and `.cjs` outputs. + +### `@os-legal/caml-react` package.json (key fields) + +```json +{ + "name": "@os-legal/caml-react", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "dependencies": { + "@os-legal/caml": "workspace:*" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17", + "styled-components": ">=5", + "react-markdown": ">=8", + "remark-gfm": ">=3", + "rehype-sanitize": ">=5" + }, + "peerDependenciesMeta": { + "react-markdown": { "optional": true }, + "remark-gfm": { "optional": true }, + "rehype-sanitize": { "optional": true } + } +} +``` + +Markdown peer deps are optional — only needed if the consumer does not provide a custom `renderMarkdown` prop. + +## `@os-legal/caml` — Parser Package + +### Public API + +Export all types defined in `types.ts`, including leaf sub-types needed by custom renderer implementations: + +```typescript +export { parseCaml } from "./tokenizer"; +export type { + // Top-level document + CamlDocument, CamlFrontmatter, CamlChapter, + + // Hero & Footer + CamlHero, CamlFooter, CamlFooterNav, + + // Block union + individual block types + CamlBlock, + CamlProse, + CamlCards, CamlCardItem, + CamlPills, CamlPillItem, + CamlTabs, CamlTab, CamlTabSection, CamlTabSource, + CamlTimeline, CamlTimelineLegendItem, CamlTimelineItem, + CamlCta, CamlCtaButton, + CamlSignup, + CamlCorpusStats, CamlCorpusStatItem, + CamlAnnotationEmbed, +}; +``` + +### Changes from Current Code + +None to the logic. Only change: remove OC-specific comment in `types.ts`: + +```typescript +// Before: +content: string; // Raw markdown (rendered by MarkdownMessageRenderer) +// After: +content: string; // Raw markdown +``` + +### Test Migration + +Both test suites transfer as-is with no changes: +- `parseCaml.test.ts` (409 lines) → `packages/caml/__tests__/parseCaml.test.ts` +- `safeHref.test.ts` (60 lines) → `packages/caml-react/__tests__/safeHref.test.ts` + +## `@os-legal/caml-react` — Theme System + +### `CamlTheme` Interface + +Derived from actual token usage in the current `styles.ts` (80+ references to `OS_LEGAL_COLORS`/`OS_LEGAL_TYPOGRAPHY` + 4 references to `accentAlpha`): + +```typescript +export interface CamlTheme { + colors: { + accent: string; + accentHover: string; + textPrimary: string; + textSecondary: string; + textTertiary: string; + textMuted: string; + surface: string; + surfaceLight: string; + surfaceHover: string; + border: string; + heading: string; // CAML-specific: deep dark slate for headings + proseText: string; // CAML-specific: article body text + darkProse: string; // CAML-specific: text on dark backgrounds + }; + typography: { + fontFamilySans: string; + fontFamilySerif: string; + }; + /** Derive an rgba string from the accent color at a given opacity. */ + accentAlpha: (opacity: number) => string; +} +``` + +### `CamlStats` Type + +Shared stats shape used by `CamlArticle`, `CamlChapter`, and `CorpusStatsBlock`: + +```typescript +export interface CamlStats { + annotations?: number; + documents?: number; + contributors?: number; + threads?: number; +} +``` + +### Default Theme + +Ships the current OS Legal look out of the box. Values sourced directly from `OS_LEGAL_COLORS` in `osLegalStyles.ts`: + +```typescript +export const defaultCamlTheme: CamlTheme = { + colors: { + accent: "#0f766e", + accentHover: "#0d6860", + textPrimary: "#1e293b", + textSecondary: "#64748b", + textTertiary: "#475569", + textMuted: "#94a3b8", + surface: "white", + surfaceLight: "#f1f5f9", + surfaceHover: "#f8fafc", + border: "#e2e8f0", + heading: "#0f172a", + proseText: "#334155", + darkProse: "#cbd5e1", + }, + typography: { + fontFamilySans: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif', + fontFamilySerif: '"Georgia", "Times New Roman", serif', + }, + accentAlpha: (opacity) => `rgba(15, 118, 110, ${opacity})`, +}; +``` + +### Utilities + +`theme.ts` also provides `DeepPartial` and `deepMerge` — kept internal (not exported from the public API): + +```typescript +/** Recursively make all properties optional. */ +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +/** Shallow-merge nested objects (2 levels deep — sufficient for CamlTheme). */ +function deepMerge(base: CamlTheme, overrides?: DeepPartial): CamlTheme { + if (!overrides) return base; + return { + colors: { ...base.colors, ...overrides.colors }, + typography: { ...base.typography, ...overrides.typography }, + accentAlpha: overrides.accentAlpha ?? base.accentAlpha, + }; +} +``` + +### `CamlThemeProvider` + +Wraps styled-components' `ThemeProvider` with CAML tokens namespaced under `theme.caml`. Uses the function form of `ThemeProvider` to preserve any existing consumer theme keys: + +```typescript +import { createContext, useContext } from "react"; +import { ThemeProvider } from "styled-components"; + +const CamlThemeContext = createContext(defaultCamlTheme); +export const useCamlTheme = () => useContext(CamlThemeContext); + +export function CamlThemeProvider({ + theme: overrides, + children, +}: { + theme?: DeepPartial; + children: ReactNode; +}) { + const merged = deepMerge(defaultCamlTheme, overrides); + return ( + + ({ ...outerTheme, caml: merged })}> + {children} + + + ); +} +``` + +### Impact on `styles.ts` + +Every direct token reference becomes a theme read via styled-components' prop injection: + +```typescript +// Before (in OC): +color: ${OS_LEGAL_COLORS.accent}; +background: ${accentAlpha(0.04)}; + +// After (in library): +color: ${({ theme }) => theme.caml.colors.accent}; +background: ${({ theme }) => theme.caml.accentAlpha(0.04)}; +``` + +This is a mechanical find-and-replace across ~80 sites in `styles.ts`. No logic changes. + +## `@os-legal/caml-react` — Markdown Rendering + +### Render Slot Pattern + +`CamlArticle` accepts optional render functions for markdown and annotation embeds: + +```typescript +interface CamlArticleProps { + document: CamlDocument; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} +``` + +### Prop Threading + +`renderMarkdown` is threaded through the full component hierarchy: + +1. `CamlArticle` passes `renderMarkdown` to each `CamlChapterRenderer` +2. `CamlChapterRenderer` passes it to `CamlBlockRenderer` +3. `CamlBlockRenderer` passes it to individual block components that render markdown + +The block components that consume `renderMarkdown`: +- `ProseBlock` — renders markdown content segments (line 126 in current code) +- `TabsBlock` — renders markdown in tab section content (line 258 in current code) + +Internal shared props interface: + +```typescript +interface BlockRendererProps { + block: CamlBlock; + dark?: boolean; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} +``` + +Each sub-block component (`ProseBlock`, `TabsBlock`, etc.) receives `renderMarkdown` and falls back to ``: + +```typescript +function ProseBlock({ block, dark, renderMarkdown }: ProseBlockProps) { + // ... + const renderMd = (content: string) => + renderMarkdown ? renderMarkdown(content) : ; + // ... +} +``` + +### Annotation Embed Placeholder + +The current `annotation-embed` block renders a placeholder (`"Annotation embed (coming soon)"`). In the library, this becomes a second optional render slot: + +```typescript +interface CamlArticleProps { + document: CamlDocument; + stats?: CamlStats; + renderMarkdown?: (content: string) => ReactNode; + renderAnnotationEmbed?: (ref: string) => ReactNode; +} +``` + +If not provided, the existing "coming soon" placeholder renders. OC (or any consumer) can provide a real implementation when annotation embedding is built. + +### Default `CamlMarkdown` Component + +Thin wrapper around `react-markdown` with GFM and sanitization: + +```typescript +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeSanitize from "rehype-sanitize"; + +export function CamlMarkdown({ content }: { content: string }) { + return ( + + {content} + + ); +} +``` + +When `renderMarkdown` is not provided, block components fall back to `CamlMarkdown`. If the optional peer deps (`react-markdown`, `remark-gfm`, `rehype-sanitize`) are not installed, the import fails at build time with a clear error. + +### `@os-legal/caml-react` Public API + +```typescript +// Components +export { CamlArticle } from "./CamlArticle"; +export type { CamlArticleProps } from "./CamlArticle"; +export { CamlThemeProvider } from "./CamlThemeProvider"; +export { CamlMarkdown } from "./CamlMarkdown"; + +// Theme +export { defaultCamlTheme } from "./theme"; +export type { CamlTheme, CamlStats } from "./theme"; + +// Types re-exported from @os-legal/caml for convenience +export type { CamlDocument } from "@os-legal/caml"; +``` + +## OpenContracts Integration (Consumer Side) + +After extraction, `frontend/src/caml/` is deleted entirely. Two files change: + +### `CamlArticleEditor.tsx` + +```typescript +// Before: +import { parseCaml } from "../../caml"; +import { CamlArticle } from "../../caml"; + +// After: +import { parseCaml } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +import { MarkdownMessageRenderer } from "../threads/MarkdownMessageRenderer"; + +// In the render — no theme override needed since the default theme +// matches the OS Legal design system values exactly: + + } + /> + +``` + +Since `defaultCamlTheme` is sourced directly from `OS_LEGAL_COLORS` values, OC does not need to pass a custom theme unless the design system tokens diverge in the future. + +### `CorpusArticleView.tsx` + +Same pattern — update imports, wrap in `CamlThemeProvider`, pass `renderMarkdown`. + +### Deleted + +- `frontend/src/caml/` — entire directory (~2,000 lines) + +### Unchanged + +- `CAML_ARTICLE_FILENAME` constant stays in OC (`frontend/src/assets/configurations/constants.ts`) — app-level convention, not a format concern. +- Backend `MarkdownParser` (`opencontractserver/pipeline/parsers/oc_markdown_parser.py`) — unrelated to the frontend library. + +### Net OC Diff + +~20 lines changed across 2 files, ~2,000 lines deleted. + +## Limitations / Non-goals (v0.1) + +- **SSR**: No server-side rendering support. styled-components requires additional setup (babel/SWC plugin) for SSR environments like Next.js/Remix. Out of scope for v0.1. +- **Dark mode toggle**: The theme system supports dark chapter sections within articles, but a full page-level dark mode is not included. +- **CAML v2 features**: `corpus-stats` live data binding, AI citation (`{{cite-me}}`), and annotation embedding are not implemented in the library. The `renderAnnotationEmbed` slot provides the extension point. + +## Development Workflow + +During initial development, use yarn `link:` protocol or `file:` references in OC's `package.json` to develop both repos side-by-side: + +```json +{ + "dependencies": { + "@os-legal/caml": "link:../os-legal-caml/packages/caml", + "@os-legal/caml-react": "link:../os-legal-caml/packages/caml-react" + } +} +``` + +Once published to npm, OC consumes the packages normally via versioned dependencies. diff --git a/frontend/package.json b/frontend/package.json index 04d0eda4e..61b0edbcb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ "@dnd-kit/utilities": "^3.2.2", "@floating-ui/dom": "^1.6.0", "@hello-pangea/dnd": "^18.0.1", + "@os-legal/caml": "^0.0.1", + "@os-legal/caml-react": "^0.0.1", "@os-legal/ui": "0.1.16", "@rjsf/core": "^5.24.12", "@rjsf/utils": "^5.24.1", @@ -124,6 +126,7 @@ "**/*": "prettier --write --ignore-unknown" }, "resolutions": { + "@os-legal/caml": "^0.0.1", "@rjsf/validator-ajv8/ajv": "8.18.0", "ajv-formats/ajv": "8.18.0", "glob": "10.5.0", diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index 4083ddb91..572bad886 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -20,6 +20,10 @@ export const MENTION_SEARCH_MIN_CHARS = 2; // Used for truncating annotation text in mention chips and pickers export const MENTION_PREVIEW_LENGTH = 24; +// CAML article configuration +// The conventional document title for corpus articles (like GitHub's README) +export const CAML_ARTICLE_FILENAME = "Readme.CAML"; + // Label/UI colors // Default neutral gray color (Tailwind slate-400) used for inactive/placeholder states export const DEFAULT_LABEL_COLOR = "94a3b8"; diff --git a/frontend/src/components/corpuses/CamlArticleEditor.tsx b/frontend/src/components/corpuses/CamlArticleEditor.tsx new file mode 100644 index 000000000..0c98dbd4d --- /dev/null +++ b/frontend/src/components/corpuses/CamlArticleEditor.tsx @@ -0,0 +1,475 @@ +/** + * CamlArticleEditor — Full-screen modal editor for Readme.CAML articles. + * + * Supports both creating new articles and editing existing ones. + * Uses the UploadDocument mutation to create/version the Readme.CAML document. + * Preview pane renders the parsed CAML via CamlArticle renderer. + */ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { useQuery, useMutation } from "@apollo/client"; +import { toast } from "react-toastify"; +import { BookOpen, Check, Eye, Edit, Save } from "lucide-react"; +import styled from "styled-components"; + +import { Modal } from "@os-legal/ui"; +import { ConfirmModal } from "../widgets/modals/ConfirmModal"; +import { OS_LEGAL_COLORS } from "../../assets/configurations/osLegalStyles"; +import { CAML_ARTICLE_FILENAME } from "../../assets/configurations/constants"; +import { + GET_CORPUS_ARTICLE, + GetCorpusArticleInput, + GetCorpusArticleOutput, +} from "../../graphql/queries"; +import { + UPLOAD_DOCUMENT, + UploadDocumentInputProps, + UploadDocumentOutputProps, +} from "../../graphql/mutations"; +import { parseCaml } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +import { MarkdownMessageRenderer } from "../threads/MarkdownMessageRenderer"; + +// --------------------------------------------------------------------------- +// Styled components +// --------------------------------------------------------------------------- + +const StyledModalWrapper = styled.div` + .modal-overlay { + z-index: 1000; + } + + [class*="modal-content"], + [class*="ModalContent"], + [role="dialog"] > div { + width: 95vw !important; + max-width: 1400px !important; + height: 90vh !important; + max-height: 90vh !important; + border-radius: 16px !important; + overflow: hidden !important; + } + + @media (max-width: 768px) { + [class*="modal-content"], + [class*="ModalContent"], + [role="dialog"] > div { + width: 100vw !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + } + } +`; + +const ModalHeader = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid ${OS_LEGAL_COLORS.border}; + background: ${OS_LEGAL_COLORS.background}; + + h2 { + font-size: 1rem; + font-weight: 600; + color: ${OS_LEGAL_COLORS.textPrimary}; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + flex: 1; + } +`; + +const ContentWrapper = styled.div` + display: flex; + height: calc(90vh - 60px - 64px); + overflow: hidden; + + @media (max-width: 768px) { + flex-direction: column; + height: calc(100vh - 60px - 64px); + } +`; + +const EditorPane = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + border-right: 1px solid ${OS_LEGAL_COLORS.border}; +`; + +const PaneHeader = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: ${OS_LEGAL_COLORS.surfaceHover}; + border-bottom: 1px solid ${OS_LEGAL_COLORS.border}; + font-size: 0.75rem; + font-weight: 600; + color: ${OS_LEGAL_COLORS.textSecondary}; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +const EditorTextarea = styled.textarea` + flex: 1; + width: 100%; + padding: 1rem; + border: none; + resize: none; + font-family: "SF Mono", Monaco, "Cascadia Code", monospace; + font-size: 0.875rem; + line-height: 1.6; + color: ${OS_LEGAL_COLORS.textPrimary}; + background: ${OS_LEGAL_COLORS.surface}; + outline: none; + + &::placeholder { + color: ${OS_LEGAL_COLORS.textMuted}; + } +`; + +const PreviewPane = styled.div` + flex: 1; + overflow-y: auto; + background: ${OS_LEGAL_COLORS.surface}; + min-width: 0; +`; + +const ActionBar = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + border-top: 1px solid ${OS_LEGAL_COLORS.border}; + background: ${OS_LEGAL_COLORS.background}; +`; + +const ActionButton = styled.button<{ $primary?: boolean }>` + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + border: 1px solid + ${({ $primary }) => + $primary ? OS_LEGAL_COLORS.accent : OS_LEGAL_COLORS.border}; + background: ${({ $primary }) => + $primary ? OS_LEGAL_COLORS.accent : OS_LEGAL_COLORS.surface}; + color: ${({ $primary }) => + $primary ? OS_LEGAL_COLORS.surface : OS_LEGAL_COLORS.textPrimary}; + + &:hover { + background: ${({ $primary }) => + $primary ? OS_LEGAL_COLORS.accentHover : OS_LEGAL_COLORS.surfaceHover}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const UnsavedBadge = styled.span` + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + background: ${OS_LEGAL_COLORS.warningSurface}; + color: ${OS_LEGAL_COLORS.warningText}; +`; + +const CAML_TEMPLATE = `--- +version: "1.0" + +hero: + kicker: "Your organization · Interactive analysis" + title: + - "Your article" + - "{title here}" + subtitle: > + Write a compelling subtitle that describes what this + article is about and why readers should care. + stats: + - "Documents analyzed" + - "Key findings" +--- + +::: chapter {#introduction} +>! Chapter 1 +## Getting started + +Write your article content here using CAML syntax. +You can use **bold**, *italic*, and [links](https://example.com). + +>>> "Use triple blockquotes for pullquotes that stand out." + +:::: cards {columns: 2} + +- **Key Finding 1** | #0f766e + Describe the first key finding here. + ~ Source: Document A + +- **Key Finding 2** | #c4573a + Describe the second key finding here. + ~ Source: Document B + +:::: + +::: + +::: chapter {#case-tracker} +>! Chapter 2 +## Case History + +:::: case-history +title: Example Case v. Sample Corp +docket: No. 24-cv-01234 (S.D.N.Y.) +status: Pending + +- District Court | S.D.N.Y. | 2024-03-15 | Motion to Dismiss | Denied + Court found sufficient facts to proceed. + +- Court of Appeals | 2nd Circuit | 2025-01-20 | Appeal | Pending + Oral arguments scheduled. + +:::: + +::: + +::: chapter {#jurisdiction} +>! Chapter 3 +## Jurisdiction Map + +:::: map {type: us} +legend: +- Compliant | #0f766e +- Pending | #f59e0b +- Non-compliant | #dc2626 + +- CA | Compliant +- NY | Compliant +- TX | Pending +- FL | Non-compliant +- IL | Compliant + +:::: + +::: +`; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface CamlArticleEditorProps { + corpusId: string; + isOpen: boolean; + onClose: () => void; + onUpdate?: () => void; +} + +export const CamlArticleEditor: React.FC = ({ + corpusId, + isOpen, + onClose, + onUpdate, +}) => { + const [content, setContent] = useState(""); + const [currentContent, setCurrentContent] = useState(""); + const [hasChanges, setHasChanges] = useState(false); + const [isNew, setIsNew] = useState(false); + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + + // Query for existing Readme.CAML + const articleVars = useMemo( + () => ({ corpusId, title: CAML_ARTICLE_FILENAME }), + [corpusId] + ); + + const { data, refetch } = useQuery< + GetCorpusArticleOutput, + GetCorpusArticleInput + >(GET_CORPUS_ARTICLE, { + variables: articleVars, + skip: !isOpen, + }); + + const [uploadDocument, { loading: saving }] = useMutation< + UploadDocumentOutputProps, + UploadDocumentInputProps + >(UPLOAD_DOCUMENT); + + const articleDoc = data?.documents?.edges?.[0]?.node; + + // Load content from existing document or start with template + useEffect(() => { + if (!isOpen) return; + + if (articleDoc?.txtExtractFile) { + setIsNew(false); + fetch(articleDoc.txtExtractFile) + .then((res) => res.text()) + .then((text) => { + setContent(text); + setCurrentContent(text); + setHasChanges(false); + }) + .catch((err) => { + console.error("Failed to fetch article content:", err); + setContent(CAML_TEMPLATE); + setCurrentContent(CAML_TEMPLATE); + }); + } else if (data && !articleDoc) { + // No existing article — start fresh + setIsNew(true); + setContent(CAML_TEMPLATE); + setCurrentContent(CAML_TEMPLATE); + setHasChanges(false); + } + }, [articleDoc, data, isOpen]); + + // Track changes + useEffect(() => { + setHasChanges(content !== currentContent); + }, [content, currentContent]); + + // Parse content for preview + const parsedDocument = useMemo(() => { + try { + return parseCaml(content); + } catch { + return null; + } + }, [content]); + + const handleSave = useCallback(async () => { + if (!hasChanges && !isNew) return; + + try { + // Encode content as base64 for the upload mutation + const bytes = new TextEncoder().encode(content); + const base64Content = btoa( + Array.from(bytes, (b) => String.fromCharCode(b)).join("") + ); + + const result = await uploadDocument({ + variables: { + base64FileString: base64Content, + filename: CAML_ARTICLE_FILENAME, + title: CAML_ARTICLE_FILENAME, + description: "Corpus article (CAML format)", + customMeta: {}, + makePublic: false, + addToCorpusId: corpusId, + }, + }); + + if (result.data?.uploadDocument.ok) { + toast.success(isNew ? "Article created!" : "Article updated!", { + icon: , + }); + setCurrentContent(content); + setHasChanges(false); + setIsNew(false); + await refetch(); + onUpdate?.(); + } else { + toast.error( + result.data?.uploadDocument.message || "Failed to save article" + ); + } + } catch (error) { + console.error("Error saving article:", error); + toast.error("Failed to save article"); + } + }, [content, hasChanges, isNew, corpusId, uploadDocument, refetch, onUpdate]); + + const handleClose = () => { + if (hasChanges) { + setShowCloseConfirm(true); + } else { + onClose(); + } + }; + + return ( + + + +

+ + {isNew ? "Create Article" : "Edit Article"} + {hasChanges && Unsaved changes} +

+
+ + + + + + CAML Source + + setContent(e.target.value)} + placeholder="Write your CAML article here..." + spellCheck={false} + /> + + + + + + Preview + + {parsedDocument && ( + + ( + + )} + /> + + )} + + + + + Close + + + {saving ? "Saving..." : isNew ? "Create Article" : "Save Changes"} + + +
+ + {}} + toggleModal={() => setShowCloseConfirm(false)} + confirmVariant="danger" + confirmLabel="Discard" + cancelLabel="Keep editing" + /> +
+ ); +}; diff --git a/frontend/src/components/corpuses/CorpusHome.tsx b/frontend/src/components/corpuses/CorpusHome.tsx index 18fab179d..578a49a82 100644 --- a/frontend/src/components/corpuses/CorpusHome.tsx +++ b/frontend/src/components/corpuses/CorpusHome.tsx @@ -1,6 +1,8 @@ -import React from "react"; -import { useReactiveVar } from "@apollo/client"; +import React, { useMemo } from "react"; +import { useReactiveVar, useQuery } from "@apollo/client"; import { useLocation, useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import { Zap } from "lucide-react"; import { corpusDetailView } from "../../graphql/cache"; import { @@ -8,13 +10,44 @@ import { navigateToDiscussionThread, } from "../../utils/navigationUtils"; import { CorpusType } from "../../types/graphql-api"; +import { + GET_CORPUS_ARTICLE, + GetCorpusArticleInput, + GetCorpusArticleOutput, +} from "../../graphql/queries"; +import { CAML_ARTICLE_FILENAME } from "../../assets/configurations/constants"; +import { OS_LEGAL_COLORS } from "../../assets/configurations/osLegalStyles"; import { CorpusLandingView } from "./CorpusHome/CorpusLandingView"; import { CorpusDetailsView } from "./CorpusHome/CorpusDetailsView"; import { CorpusDiscussionsInlineView } from "./CorpusHome/CorpusDiscussionsInlineView"; +import { CorpusArticleView } from "./CorpusHome/CorpusArticleView"; +import { InlineChatBar } from "./CorpusHero/InlineChatBar"; +import { PillToggle, PillToggleLabel } from "./CorpusHome/styles"; + +/** Floating pill bar overlaid on the article landing view */ +const FloatingControls = styled.div` + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 20; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border: 1px solid ${OS_LEGAL_COLORS.border}; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + max-width: 600px; + width: calc(100% - 3rem); +`; export interface CorpusHomeProps { corpus: CorpusType; onEditDescription: () => void; + onEditArticle?: () => void; onNavigate?: (tabIndex: number) => void; onBack?: () => void; canUpdate?: boolean; @@ -23,6 +56,7 @@ export interface CorpusHomeProps { totalAnnotations: number; totalAnalyses: number; totalExtracts: number; + totalThreads: number; }; statsLoading: boolean; // Chat integration props @@ -50,10 +84,13 @@ export interface CorpusHomeProps { * - /c/user/corpus → Landing view (default) * - /c/user/corpus?view=details → Details view * - /c/user/corpus?view=discussions → Discussions view + * - /c/user/corpus?view=article → Article view (Readme.CAML) */ export const CorpusHome: React.FC = ({ corpus, onEditDescription, + onEditArticle, + stats, chatQuery = "", onChatQueryChange, onChatSubmit, @@ -69,6 +106,26 @@ export const CorpusHome: React.FC = ({ // Get current view from URL-driven reactive var (set by CentralRouteManager) const currentView = useReactiveVar(corpusDetailView); + // Detect whether the corpus has a Readme.CAML article. + // When it does and we're on the default landing view, the article becomes + // the home page with floating controls overlaid. + const articleQueryVars = useMemo( + () => ({ + corpusId: corpus.id, + title: CAML_ARTICLE_FILENAME, + }), + [corpus.id] + ); + + const { data: articleData } = useQuery< + GetCorpusArticleOutput, + GetCorpusArticleInput + >(GET_CORPUS_ARTICLE, { variables: articleQueryVars }); + + const hasArticle = + (articleData?.documents?.edges?.length ?? 0) > 0 && + !!articleData?.documents?.edges[0]?.node?.txtExtractFile; + // Handle switching to details view const handleViewDetails = () => { updateDetailViewParam(location, navigate, "details"); @@ -89,6 +146,11 @@ export const CorpusHome: React.FC = ({ updateDetailViewParam(location, navigate, "discussions"); }; + // Handle switching to article view + const handleViewArticle = () => { + updateDetailViewParam(location, navigate, "article"); + }; + // Handle clicking a specific thread from the landing page feed const handleThreadClick = (threadId: string) => { navigateToDiscussionThread(location, navigate, threadId); @@ -117,6 +179,74 @@ export const CorpusHome: React.FC = ({ ); } + if (currentView === "article") { + return ( + + ); + } + + // When a Readme.CAML exists, render the article as the default landing view + // with floating chat and mode-toggle controls overlaid at the bottom. + if (hasArticle) { + return ( +
+ + +
+ {})} + onSubmit={onChatSubmit || (() => {})} + onViewHistory={onViewChatHistory || (() => {})} + showQuickActions={false} + autoFocus={false} + testId="corpus-article-chat" + /> +
+ {onModeToggle && ( + + + Focus + + + + Power + + + )} +
+
+ ); + } + return ( = ({ onModeToggle={onModeToggle} isPowerUserMode={isPowerUserMode} onViewDiscussions={handleViewDiscussions} + onViewArticle={handleViewArticle} onThreadClick={handleThreadClick} testId="corpus-home-landing" /> diff --git a/frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx b/frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx new file mode 100644 index 000000000..5739e7f38 --- /dev/null +++ b/frontend/src/components/corpuses/CorpusHome/CorpusArticleView.tsx @@ -0,0 +1,269 @@ +/** + * CorpusArticleView — Renders a CAML article stored as Readme.CAML + * in the corpus documents. + * + * Fetches the Readme.CAML document, parses its content, and renders + * the full scrollytelling article experience. + */ +import React, { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@apollo/client"; +import { ArrowLeft, FileText, Edit } from "lucide-react"; +import styled from "styled-components"; + +import { OS_LEGAL_COLORS } from "../../../assets/configurations/osLegalStyles"; + +import { + GET_CORPUS_ARTICLE, + GetCorpusArticleInput, + GetCorpusArticleOutput, +} from "../../../graphql/queries"; +import { CorpusType } from "../../../types/graphql-api"; +import { parseCaml } from "@os-legal/caml"; +import type { CamlDocument } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; +import { MarkdownMessageRenderer } from "../../threads/MarkdownMessageRenderer"; +import { CAML_ARTICLE_FILENAME } from "../../../assets/configurations/constants"; + +// --------------------------------------------------------------------------- +// Styled components +// --------------------------------------------------------------------------- + +const ArticleViewContainer = styled.div` + width: 100%; + min-height: 100vh; + background: ${OS_LEGAL_COLORS.surface}; +`; + +const ArticleToolbar = styled.div` + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-bottom: 1px solid ${OS_LEGAL_COLORS.border}; +`; + +const BackButton = styled.button` + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid ${OS_LEGAL_COLORS.border}; + border-radius: 6px; + background: ${OS_LEGAL_COLORS.surface}; + color: ${OS_LEGAL_COLORS.textTertiary}; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: ${OS_LEGAL_COLORS.surfaceHover}; + border-color: ${OS_LEGAL_COLORS.borderHover}; + } +`; + +const ToolbarTitle = styled.span` + font-size: 0.8125rem; + color: ${OS_LEGAL_COLORS.textMuted}; + font-weight: 500; +`; + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 1rem; + color: ${OS_LEGAL_COLORS.textSecondary}; +`; + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 1rem; + color: ${OS_LEGAL_COLORS.textSecondary}; + text-align: center; + padding: 2rem; +`; + +const EmptyIcon = styled.div` + width: 64px; + height: 64px; + border-radius: 16px; + background: ${OS_LEGAL_COLORS.surfaceLight}; + display: flex; + align-items: center; + justify-content: center; + color: ${OS_LEGAL_COLORS.textMuted}; +`; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export interface CorpusArticleViewProps { + corpus: CorpusType; + onBack: () => void; + onEditArticle?: () => void; + stats?: { + annotations?: number; + documents?: number; + contributors?: number; + threads?: number; + }; + testId?: string; +} + +export const CorpusArticleView: React.FC = ({ + corpus, + onBack, + onEditArticle, + stats, + testId = "corpus-article", +}) => { + const [camlContent, setCamlContent] = useState(null); + const [fetchError, setFetchError] = useState(null); + + // Query for Readme.CAML document in this corpus + const queryVars = useMemo( + () => ({ + corpusId: corpus.id, + title: CAML_ARTICLE_FILENAME, + }), + [corpus.id] + ); + + const { data, loading } = useQuery< + GetCorpusArticleOutput, + GetCorpusArticleInput + >(GET_CORPUS_ARTICLE, { + variables: queryVars, + }); + + const articleDoc = data?.documents?.edges?.[0]?.node; + + // Fetch the CAML content from the txtExtractFile URL + useEffect(() => { + if (!articleDoc?.txtExtractFile) { + setCamlContent(null); + return; + } + + fetch(articleDoc.txtExtractFile) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then((text) => { + setCamlContent(text); + setFetchError(null); + }) + .catch((err) => { + console.error("Failed to fetch CAML content:", err); + setFetchError(err.message); + setCamlContent(null); + }); + }, [articleDoc?.txtExtractFile]); + + // Parse CAML content + const parsedDocument: CamlDocument | null = useMemo(() => { + if (!camlContent) return null; + try { + return parseCaml(camlContent); + } catch (err) { + console.error("Failed to parse CAML:", err); + return null; + } + }, [camlContent]); + + if (loading) { + return ( + + + + + Back + + + +

Loading article...

+
+
+ ); + } + + if (!articleDoc || fetchError) { + return ( + + + + + Back + + + + + + +

No article found for this corpus.

+

+ Upload a Readme.CAML document to create one. +

+
+
+ ); + } + + if (!parsedDocument) { + return ( + + + + + Back + + + +

Parsing article...

+
+
+ ); + } + + return ( + + + + + Back + + {corpus.title} + {onEditArticle && ( + + + Edit + + )} + + + + } + /> + + + ); +}; diff --git a/frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx b/frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx index 0f9e3ca48..8b36df584 100644 --- a/frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx +++ b/frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx @@ -11,6 +11,7 @@ import { Plus, Menu, Zap, + BookOpen, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; @@ -18,7 +19,11 @@ import { GET_CORPUS_WITH_HISTORY, GetCorpusWithHistoryQuery, GetCorpusWithHistoryQueryVariables, + GET_CORPUS_ARTICLE, + GetCorpusArticleInput, + GetCorpusArticleOutput, } from "../../../graphql/queries"; +import { CAML_ARTICLE_FILENAME } from "../../../assets/configurations/constants"; import { CorpusType } from "../../../types/graphql-api"; import { PermissionTypes } from "../../types"; import { getPermissions } from "../../../utils/transform"; @@ -69,6 +74,8 @@ export interface CorpusLandingViewProps { onOpenMobileMenu?: () => void; /** Callback when "View All Discussions" is clicked */ onViewDiscussions?: () => void; + /** Callback when "Read Article" is clicked */ + onViewArticle?: () => void; /** Callback when a specific thread is clicked from the feed */ onThreadClick?: (threadId: string) => void; /** Callback when mode toggle is clicked (only shown when present) */ @@ -106,6 +113,7 @@ export const CorpusLandingView: React.FC = ({ onViewChatHistory, onOpenMobileMenu, onViewDiscussions, + onViewArticle, onThreadClick, onModeToggle, isPowerUserMode = false, @@ -124,6 +132,18 @@ export const CorpusLandingView: React.FC = ({ variables: historyVariables, }); + // Check if a Readme.CAML article exists in this corpus + const articleVars = useMemo( + () => ({ corpusId: corpus.id, title: CAML_ARTICLE_FILENAME }), + [corpus.id] + ); + const { data: articleData } = useQuery< + GetCorpusArticleOutput, + GetCorpusArticleInput + >(GET_CORPUS_ARTICLE, { variables: articleVars }); + + const hasArticle = (articleData?.documents?.edges?.length ?? 0) > 0; + // Fetch markdown content from URL useEffect(() => { if (corpusData?.corpus?.mdDescription) { @@ -332,6 +352,18 @@ export const CorpusLandingView: React.FC = ({ /> + {/* Read article — shown when Readme.CAML exists */} + {hasArticle && onViewArticle && ( + + + Read the article + + + )} + {/* Browse documents — subtle text link */} (false); * /c/user/corpus → corpusDetailView("landing") = default landing * /c/user/corpus?view=details → corpusDetailView("details") * /c/user/corpus?view=discussions → corpusDetailView("discussions") + * /c/user/corpus?view=article → corpusDetailView("article") */ -export type CorpusDetailViewType = "landing" | "details" | "discussions"; +export type CorpusDetailViewType = + | "landing" + | "details" + | "discussions" + | "article"; export const corpusDetailView = makeVar("landing"); /** diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index 8a64996cd..4559bfd02 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -5125,6 +5125,49 @@ export const GET_CORPUS_DOCUMENTS_FOR_TOC = gql` } `; +// ============================================================================ +// CAML ARTICLE (Readme.CAML document) +// ============================================================================ + +export const GET_CORPUS_ARTICLE = gql` + query GetCorpusArticle($corpusId: String!, $title: String!) { + documents(inCorpusWithId: $corpusId, title: $title, first: 1) { + edges { + node { + id + title + txtExtractFile + modified + creator { + email + } + } + } + } + } +`; + +export interface GetCorpusArticleInput { + corpusId: string; + title: string; +} + +export interface GetCorpusArticleOutput { + documents: { + edges: Array<{ + node: { + id: string; + title: string; + txtExtractFile: string | null; + modified: string; + creator: { + email: string; + }; + }; + }>; + }; +} + // ============================================================================ // CORPUS ACTION TEMPLATES // ============================================================================ diff --git a/frontend/src/routing/CentralRouteManager.tsx b/frontend/src/routing/CentralRouteManager.tsx index 2ad0e9cb9..4d939acda 100644 --- a/frontend/src/routing/CentralRouteManager.tsx +++ b/frontend/src/routing/CentralRouteManager.tsx @@ -882,12 +882,14 @@ export function CentralRouteManager() { ? homeViewParam : null; - // Parse detailView param (valid values: "details", "discussions"; defaults to "landing") + // Parse detailView param (valid values: "details", "discussions", "article"; defaults to "landing") const newDetailView: CorpusDetailViewType = detailViewParam === "details" ? "details" : detailViewParam === "discussions" ? "discussions" + : detailViewParam === "article" + ? "article" : "landing"; // Collect all reactive var updates into a batch diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx index f24e1ad0d..669a49a97 100644 --- a/frontend/src/theme/ThemeProvider.tsx +++ b/frontend/src/theme/ThemeProvider.tsx @@ -1,5 +1,6 @@ import React, { useLayoutEffect, useMemo, useState } from "react"; import { ThemeProvider as SCThemeProvider } from "styled-components"; +import { defaultCamlTheme } from "@os-legal/caml-react"; import { Theme } from "./theme"; import type { DefaultTheme } from "styled-components"; @@ -27,7 +28,7 @@ export const ThemeProvider = ({ theme, children }: Props): JSX.Element => { /* 3 ▸ Merge run-time + design tokens once per change */ const mergedTheme: DefaultTheme = useMemo( - () => ({ ...baseTheme, width: viewportWidth }), + () => ({ ...baseTheme, width: viewportWidth, caml: defaultCamlTheme }), [baseTheme, viewportWidth] ); diff --git a/frontend/src/utils/navigationUtils.ts b/frontend/src/utils/navigationUtils.ts index 8479d0028..3047fc9a3 100644 --- a/frontend/src/utils/navigationUtils.ts +++ b/frontend/src/utils/navigationUtils.ts @@ -45,7 +45,7 @@ export interface QueryParams { messageId?: string | null; homeView?: "about" | "toc" | null; // corpus home view selection tocExpanded?: boolean; // true to expand all TOC nodes - view?: "landing" | "details" | "discussions" | null; // corpus detail view selection + view?: "landing" | "details" | "discussions" | "article" | null; // corpus detail view selection mode?: "power" | null; // corpus power user mode version?: number | null; // Document version number (null = current version) showStructural?: boolean; @@ -946,13 +946,13 @@ export function updateTocExpandedParam( * Pushes a new history entry so browser back/forward navigates between views. * @param location - React Router location object * @param navigate - React Router navigate function - * @param view - View identifier ("landing", "details", or "discussions") + * @param view - View identifier ("landing", "details", "discussions", or "article") * Pass "landing" or null to clear and use default (landing) */ export function updateDetailViewParam( location: LocationLike, navigate: NavigateFn, - view: "landing" | "details" | "discussions" | null + view: "landing" | "details" | "discussions" | "article" | null ) { const searchParams = new URLSearchParams(location.search); if (view && view !== "landing") { diff --git a/frontend/src/views/Corpuses.tsx b/frontend/src/views/Corpuses.tsx index e522c2e44..5006e67fd 100644 --- a/frontend/src/views/Corpuses.tsx +++ b/frontend/src/views/Corpuses.tsx @@ -147,6 +147,7 @@ import { buildQueryParams } from "../utils/navigationUtils"; import { toGlobalId } from "../utils/idValidation"; import { CorpusHome } from "../components/corpuses/CorpusHome"; import { CorpusDescriptionEditor } from "../components/corpuses/CorpusDescriptionEditor"; +import { CamlArticleEditor } from "../components/corpuses/CamlArticleEditor"; import { CorpusDiscussionsView } from "../components/discussions/CorpusDiscussionsView"; import { BadgeManagement } from "../components/badges/BadgeManagement"; import { CorpusEngagementDashboard } from "../components/analytics/CorpusEngagementDashboard"; @@ -373,6 +374,7 @@ const CorpusQueryView = ({ opened_corpus, opened_corpus_id, setShowDescriptionEditor, + setShowArticleEditor, onNavigate, onBack, canUpdate, @@ -386,6 +388,7 @@ const CorpusQueryView = ({ opened_corpus: CorpusType | null; opened_corpus_id: string | null; setShowDescriptionEditor: (show: boolean) => void; + setShowArticleEditor: (show: boolean) => void; onNavigate?: (tabIndex: number) => void; onBack?: () => void; canUpdate?: boolean; @@ -394,6 +397,7 @@ const CorpusQueryView = ({ totalAnnotations: number; totalAnalyses: number; totalExtracts: number; + totalThreads: number; }; statsLoading: boolean; onOpenMobileMenu?: () => void; @@ -571,6 +575,7 @@ const CorpusQueryView = ({ setShowDescriptionEditor(true)} + onEditArticle={() => setShowArticleEditor(true)} onNavigate={onNavigate} onBack={onBack} canUpdate={canUpdate} @@ -1643,6 +1648,7 @@ export const Corpuses = () => { const urlTab = useReactiveVar(selectedTab); const [showDescriptionEditor, setShowDescriptionEditor] = useState(false); + const [showArticleEditor, setShowArticleEditor] = useState(false); const [sidebarExpanded, setSidebarExpanded] = useState(false); // Collapsed by default, opens on hover const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -2360,6 +2366,7 @@ export const Corpuses = () => { opened_corpus={opened_corpus} opened_corpus_id={opened_corpus_id} setShowDescriptionEditor={setShowDescriptionEditor} + setShowArticleEditor={setShowArticleEditor} onNavigate={(tabIndex) => setActiveTab(tabIndex)} onBack={() => navigate("/corpuses")} canUpdate={canUpdateCorpus} @@ -2986,6 +2993,16 @@ export const Corpuses = () => { }} /> )} + {opened_corpus && showArticleEditor && ( + setShowArticleEditor(false)} + onUpdate={() => { + refetchStats(); + }} + /> + )} handleDeleteCorpus(deleting_corpus?.id)} diff --git a/frontend/tests/CamlArticle.ct.tsx b/frontend/tests/CamlArticle.ct.tsx new file mode 100644 index 000000000..7fed13593 --- /dev/null +++ b/frontend/tests/CamlArticle.ct.tsx @@ -0,0 +1,407 @@ +/** + * Playwright component tests for the CAML article rendering system. + * + * Tests cover: + * 1. Full article rendering with all block types + * 2. Hero section with accent text and stats + * 3. Cards grid rendering + * 4. Pills row rendering + * 5. Interactive tabs + * 6. Timeline rendering + * 7. CTA buttons + * 8. Dark-themed chapters + * 9. Footer navigation + */ +import { test, expect } from "@playwright/experimental-ct-react"; +import { docScreenshot } from "./utils/docScreenshot"; +import { + CamlArticleTestWrapper, + SAMPLE_CAML_DOCUMENT, +} from "./CamlArticleTestWrapper"; +import type { CamlDocument } from "@os-legal/caml"; + +test.describe("CamlArticle - Full Rendering", () => { + test("should render a complete article with hero, chapters, and footer", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Verify hero section renders (use .first() for text that appears in multiple blocks) + await expect(page.getByText("OpenContracts · Corpus Analysis")).toBeVisible( + { timeout: 5000 } + ); + await expect(page.getByText("Understanding the")).toBeVisible(); + await expect(page.getByText("42 Documents").first()).toBeVisible(); + + // Verify chapters render + await expect(page.getByText("Key Findings")).toBeVisible(); + await expect(page.getByText("Deep Analysis")).toBeVisible(); + + // Verify footer renders + await expect(page.getByText("Documentation")).toBeVisible(); + await expect(page.getByText("Published with OpenContracts")).toBeVisible(); + + await docScreenshot(page, "caml--article--full-render", { + fullPage: true, + }); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Hero Section", () => { + test("should render hero with kicker, accent title, subtitle, and stats", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Kicker + await expect(page.getByText("OpenContracts · Corpus Analysis")).toBeVisible( + { timeout: 5000 } + ); + + // Title with accent text (the {Supply Chain} should be rendered with accent styling) + await expect(page.getByText("Understanding the")).toBeVisible(); + // "Supply Chain" appears in hero + prose — use heading context + await expect(page.locator("h1").getByText("Supply Chain")).toBeVisible(); + + // Subtitle + await expect(page.getByText("An interactive exploration")).toBeVisible(); + + // Stats pills (text may also appear in prose — use .first()) + await expect(page.getByText("42 Documents").first()).toBeVisible(); + await expect(page.getByText("1,280 Annotations").first()).toBeVisible(); + await expect(page.getByText("8 Contributors").first()).toBeVisible(); + + await docScreenshot(page, "caml--hero--with-stats"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Cards Block", () => { + test("should render cards in a grid with labels, meta, body, and footer", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Wait for cards to render ("Force Majeure" appears in cards + prose — use .first()) + await expect(page.getByText("Force Majeure").first()).toBeVisible({ + timeout: 5000, + }); + + // Check all 4 cards are present (use h3 for card labels to avoid prose matches) + await expect(page.locator("h3").getByText("Indemnification")).toBeVisible(); + await expect(page.locator("h3").getByText("Termination")).toBeVisible(); + await expect(page.locator("h3").getByText("IP Rights")).toBeVisible(); + + // Check card meta + await expect(page.getByText("§ 12.1")).toBeVisible(); + + // Check card body text + await expect( + page.getByText("Present in 38 of 42 agreements") + ).toBeVisible(); + + // Check card footer + await expect(page.getByText("Last updated: Q2 2024")).toBeVisible(); + + await docScreenshot(page, "caml--cards--grid-render"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Pills Block", () => { + test("should render pills with big text, labels, and status indicators", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Wait for pills to render ("42" appears in many places — scope to pill big text) + await expect(page.getByText("1.2K")).toBeVisible({ timeout: 5000 }); + + // Check labels + await expect(page.getByText("Across 3 jurisdictions")).toBeVisible(); + + // Check status indicators + await expect(page.getByText("Complete")).toBeVisible(); + // "Active" may match other elements — use locator scoped to pill section + await expect(page.getByText(/^Active$/).first()).toBeVisible(); + + await docScreenshot(page, "caml--pills--with-status"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Tabs Block", () => { + test("should render interactive tabs and switch between them", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Wait for tabs to render + await expect(page.getByText("Risk Assessment")).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByText("Compliance")).toBeVisible(); + + // First tab content should be visible by default + await expect(page.getByText("Key Risks Identified")).toBeVisible(); + await expect(page.getByText("Supply chain disruption risk")).toBeVisible(); + + // Check source chips + await expect(page.getByText("Agreement-A.pdf")).toBeVisible(); + + await docScreenshot(page, "caml--tabs--risk-active"); + + // Click the second tab + await page.getByText("Compliance").click(); + await page.waitForTimeout(300); + + // Second tab content should now be visible + await expect(page.getByText("Regulatory Alignment")).toBeVisible(); + await expect(page.getByText("All agreements comply")).toBeVisible(); + + await docScreenshot(page, "caml--tabs--compliance-active"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Timeline Block", () => { + test("should render timeline with legend and entries", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to timeline chapter + await page.getByText("Agreement Timeline").scrollIntoViewIfNeeded(); + + // Legend ("Executed" also appears in timeline — use .first()) + await expect(page.getByText("Executed").first()).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByText("Amended").first()).toBeVisible(); + + // Timeline entries + await expect(page.getByText("Jan 2023")).toBeVisible(); + await expect(page.getByText("Master Agreement signed")).toBeVisible(); + await expect( + page.getByText("Amendment 1 — Force Majeure update") + ).toBeVisible(); + + await docScreenshot(page, "caml--timeline--with-legend"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - CTA Block", () => { + test("should render CTA buttons with primary and secondary styles", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to CTA section + await page.getByText("Explore Documents").scrollIntoViewIfNeeded(); + + // Primary button + const primaryBtn = page.getByText("Explore Documents"); + await expect(primaryBtn).toBeVisible({ timeout: 5000 }); + + // Secondary button + await expect(page.getByText("View Source Data")).toBeVisible(); + + await docScreenshot(page, "caml--cta--buttons"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Dark Theme Chapter", () => { + test("should render dark-themed chapter with gradient background", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to dark chapter + await page.getByText("Deep Analysis").scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + + await expect(page.getByText("Deep Analysis")).toBeVisible({ + timeout: 5000, + }); + + // The chapter should have a dark background - verify via visual screenshot + await docScreenshot(page, "caml--chapter--dark-gradient"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Pullquote", () => { + test("should render pullquote with styled blockquote", async ({ + mount, + page, + }) => { + const component = await mount(); + + // The pullquote text + await expect( + page.getByText("The majority of agreements include force majeure") + ).toBeVisible({ timeout: 5000 }); + + await docScreenshot(page, "caml--prose--pullquote"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Empty Document", () => { + test("should render gracefully with minimal document", async ({ + mount, + page, + }) => { + const minimalDoc: CamlDocument = { + frontmatter: {}, + chapters: [ + { + id: "minimal", + blocks: [ + { + type: "prose", + content: "A minimal CAML article with just prose.", + }, + ], + }, + ], + }; + + const component = await mount( + + ); + + await expect( + page.getByText("A minimal CAML article with just prose.") + ).toBeVisible({ timeout: 5000 }); + + await docScreenshot(page, "caml--article--minimal"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Corpus Stats Block", () => { + test("should render live corpus stats from props", async ({ + mount, + page, + }) => { + const statsDoc: CamlDocument = { + frontmatter: {}, + chapters: [ + { + id: "stats-chapter", + title: "Corpus Overview", + blocks: [ + { + type: "corpus-stats", + items: [ + { key: "documents", label: "Documents" }, + { key: "annotations", label: "Annotations" }, + { key: "contributors", label: "Contributors" }, + ], + }, + ], + }, + ], + }; + + const component = await mount( + + ); + + // Values should render from stats prop + await expect(page.getByText("42")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("1280")).toBeVisible(); + await expect(page.getByText("Documents")).toBeVisible(); + + await docScreenshot(page, "caml--corpus-stats--with-data"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Map Block", () => { + test("should render US map with categorical legend and state tiles", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to map chapter + await page.getByText("Jurisdiction Map").scrollIntoViewIfNeeded(); + + // Legend should render + await expect(page.getByText("Compliant").first()).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByText("Pending").first()).toBeVisible(); + await expect(page.getByText("Non-compliant")).toBeVisible(); + + // State tiles should render (check for state codes in tiles) + await expect(page.getByText("CA").first()).toBeVisible(); + await expect(page.getByText("NY").first()).toBeVisible(); + await expect(page.getByText("TX").first()).toBeVisible(); + + await docScreenshot(page, "caml--map--categorical"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticle - Case History Block", () => { + test("should render case history with entries and outcome badges", async ({ + mount, + page, + }) => { + const component = await mount(); + + // Scroll to case history chapter + await page.getByText("Case Tracker").scrollIntoViewIfNeeded(); + + // Case title and docket + await expect( + page.getByText("SEC v. Meridian Capital Partners LLC") + ).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("No. 22-cv-04817 (S.D.N.Y.)")).toBeVisible(); + + // Status badge + await expect(page.getByText("Affirmed").first()).toBeVisible(); + + // Court entries + await expect(page.getByText("District Court").first()).toBeVisible(); + await expect(page.getByText("Motion for TRO")).toBeVisible(); + await expect(page.getByText("Granted").first()).toBeVisible(); + + // Later entries + await expect(page.getByText("Court of Appeals").first()).toBeVisible(); + await expect(page.getByText("Cert Denied")).toBeVisible(); + + await docScreenshot(page, "caml--case-history--with-entries"); + + await component.unmount(); + }); +}); diff --git a/frontend/tests/CamlArticleEditor.ct.tsx b/frontend/tests/CamlArticleEditor.ct.tsx new file mode 100644 index 000000000..ee3196e21 --- /dev/null +++ b/frontend/tests/CamlArticleEditor.ct.tsx @@ -0,0 +1,130 @@ +/** + * Playwright component tests for the CamlArticleEditor. + * + * Tests cover: + * 1. New article mode (template loaded, "Create Article" button) + * 2. Editor pane with CAML source + * 3. Preview pane with rendered output + * 4. Unsaved changes indicator + */ +import { test, expect } from "@playwright/experimental-ct-react"; +import { docScreenshot } from "./utils/docScreenshot"; +import { CamlArticleEditorTestWrapper } from "./CamlArticleEditorTestWrapper"; + +test.describe("CamlArticleEditor - New Article", () => { + test("should render editor with template for new article", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Modal header should be visible ("Create Article" appears in header + save button) + await expect(page.getByText("Create Article").first()).toBeVisible({ + timeout: 10000, + }); + + // Editor pane should have CAML source header + await expect(page.getByText("CAML Source")).toBeVisible(); + + // Preview pane should show rendered content + await expect(page.getByText("Preview")).toBeVisible(); + + // Template content should be in the editor textarea + const textarea = page.locator("textarea"); + await expect(textarea).toBeVisible(); + const value = await textarea.inputValue(); + expect(value).toContain("hero:"); + expect(value).toContain("version:"); + + await docScreenshot(page, "caml--editor--new-article"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticleEditor - Live Preview", () => { + test("should update preview when editor content changes", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Wait for editor to load + const textarea = page.locator("textarea"); + await expect(textarea).toBeVisible({ timeout: 10000 }); + + // Clear and type new CAML content (simple structure — no YAML list for title) + await textarea.fill(`::: chapter {#test} +## Test Chapter + +Hello from the preview! +:::`); + + // Wait for preview to update + await page.waitForTimeout(500); + + // Preview should show the rendered chapter heading + await expect( + page.getByRole("heading", { name: "Test Chapter" }) + ).toBeVisible({ timeout: 5000 }); + + // Should show unsaved changes badge + await expect(page.getByText("Unsaved changes")).toBeVisible(); + + await docScreenshot(page, "caml--editor--live-preview"); + + await component.unmount(); + }); +}); + +test.describe("CamlArticleEditor - Close Behavior", () => { + test("should show close button", async ({ mount, page }) => { + const component = await mount( + + ); + + // Close button should be present in action bar + const closeButton = page.locator("button").filter({ hasText: "Close" }); + await expect(closeButton).toBeVisible({ timeout: 10000 }); + + await component.unmount(); + }); +}); + +test.describe("CamlArticleEditor - New Block Types in Template", () => { + test("should render map and case-history blocks in preview from template", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Wait for editor to load + await expect(page.getByText("Create Article").first()).toBeVisible({ + timeout: 10000, + }); + + // The textarea should contain the new block types + const textarea = page.locator("textarea"); + const value = await textarea.inputValue(); + expect(value).toContain("case-history"); + expect(value).toContain("map {type: us}"); + + // Preview pane should render these blocks + // Case history title - use testId to avoid matching the raw textarea content + await expect(page.getByTestId("case-history-title")).toBeVisible({ + timeout: 5000, + }); + + await docScreenshot(page, "caml--editor--full-template", { + fullPage: true, + }); + + await component.unmount(); + }); +}); diff --git a/frontend/tests/CamlArticleEditorTestWrapper.tsx b/frontend/tests/CamlArticleEditorTestWrapper.tsx new file mode 100644 index 000000000..13508ef17 --- /dev/null +++ b/frontend/tests/CamlArticleEditorTestWrapper.tsx @@ -0,0 +1,121 @@ +/** + * Test wrapper for CamlArticleEditor. + * + * Provides MockedProvider with GraphQL mocks for the article query + * and upload mutation. Wraps in MemoryRouter and Jotai Provider. + */ +import React from "react"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { InMemoryCache } from "@apollo/client"; +import { relayStylePagination } from "@apollo/client/utilities"; +import { MemoryRouter } from "react-router-dom"; +import { Provider } from "jotai"; + +import { CamlArticleEditor } from "../src/components/corpuses/CamlArticleEditor"; +import { GET_CORPUS_ARTICLE } from "../src/graphql/queries"; + +const SAMPLE_CAML_CONTENT = `--- +version: "1.0" + +hero: + title: + - "Test Article" +--- + +::: chapter {#intro} +## Hello World + +This is a test article. +::: +`; + +/** Mock: no existing article (new article flow) */ +const noArticleMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { corpusId: "test-corpus-1", title: "Readme.CAML" }, + }, + result: { + data: { + documents: { + edges: [], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +/** Mock: existing article */ +const existingArticleMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { corpusId: "test-corpus-1", title: "Readme.CAML" }, + }, + result: { + data: { + documents: { + edges: [ + { + node: { + id: "doc-article-1", + title: "Readme.CAML", + txtExtractFile: null, // Will be overridden by route mock + modified: "2024-01-15T12:00:00Z", + creator: { + email: "test@example.com", + __typename: "UserType", + }, + __typename: "DocumentType", + }, + __typename: "DocumentTypeEdge", + }, + ], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +function createTestCache() { + return new InMemoryCache({ + typePolicies: { + Query: { + fields: { + documents: relayStylePagination(["inCorpusWithId", "title"]), + }, + }, + DocumentType: { keyFields: ["id"] }, + }, + }); +} + +export interface CamlArticleEditorTestWrapperProps { + hasExistingArticle?: boolean; + isOpen?: boolean; + extraMocks?: MockedResponse[]; +} + +export const CamlArticleEditorTestWrapper: React.FC< + CamlArticleEditorTestWrapperProps +> = ({ hasExistingArticle = false, isOpen = true, extraMocks = [] }) => { + const baseMock = hasExistingArticle ? existingArticleMock : noArticleMock; + // Duplicate the mock for refetch + const allMocks = [baseMock, baseMock, ...extraMocks]; + + return ( + + + + {}} + onUpdate={() => {}} + /> + + + + ); +}; + +export { SAMPLE_CAML_CONTENT }; diff --git a/frontend/tests/CamlArticleTestWrapper.tsx b/frontend/tests/CamlArticleTestWrapper.tsx new file mode 100644 index 000000000..360876811 --- /dev/null +++ b/frontend/tests/CamlArticleTestWrapper.tsx @@ -0,0 +1,280 @@ +/** + * Test wrapper for CamlArticle — pure rendering, no GraphQL. + * + * Provides a pre-parsed CamlDocument for testing the renderer tree + * without network dependencies. + */ +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import type { CamlDocument } from "@os-legal/caml"; +import { CamlArticle, CamlThemeProvider } from "@os-legal/caml-react"; + +/** + * Sample parsed CAML document for test fixtures. + */ +export const SAMPLE_CAML_DOCUMENT: CamlDocument = { + frontmatter: { + version: "1.0", + hero: { + kicker: "OpenContracts · Corpus Analysis", + title: ["Understanding the", "{Supply Chain}"], + subtitle: + "An interactive exploration of supply chain agreements and their key provisions.", + stats: ["42 Documents", "1,280 Annotations", "8 Contributors"], + }, + footer: { + nav: [ + { label: "Documentation", href: "/docs" }, + { label: "GitHub", href: "https://github.com" }, + ], + notice: "Published with OpenContracts", + }, + }, + chapters: [ + { + id: "overview", + kicker: "Chapter 1", + title: "Key Findings", + blocks: [ + { + type: "prose", + content: + 'This corpus contains **42 supply chain agreements** spanning multiple industries.\n\n>>> "The majority of agreements include force majeure clauses that were updated post-2020."', + }, + { + type: "cards", + columns: 2, + items: [ + { + label: "Force Majeure", + meta: "§ 12.1", + accent: "#0f766e", + body: "Present in 38 of 42 agreements with pandemic-specific language.", + footer: "Last updated: Q2 2024", + }, + { + label: "Indemnification", + meta: "§ 8.3", + accent: "#c4573a", + body: "Mutual indemnification in 29 agreements; one-sided in 13.", + footer: "Avg. cap: $2.5M", + }, + { + label: "Termination", + meta: "§ 15.2", + accent: "#7c3aed", + body: "30-day notice period standard. 6 agreements allow immediate termination.", + }, + { + label: "IP Rights", + meta: "§ 6.1", + accent: "#0369a1", + body: "Ownership retained by licensor in all agreements.", + footer: "No exceptions found", + }, + ], + }, + { + type: "pills", + items: [ + { + bigText: "42", + label: "Documents", + detail: "Across 3 jurisdictions", + status: "Complete", + statusColor: "#16a34a", + }, + { + bigText: "1.2K", + label: "Annotations", + detail: "Manual + AI-assisted", + }, + { + bigText: "8", + label: "Contributors", + status: "Active", + statusColor: "#0f766e", + }, + ], + }, + ], + }, + { + id: "analysis", + theme: "dark", + gradient: true, + centered: true, + kicker: "Chapter 2", + title: "Deep Analysis", + blocks: [ + { + type: "tabs", + tabs: [ + { + label: "Risk Assessment", + status: "High", + color: "#ef4444", + sections: [ + { + heading: "Key Risks Identified", + highlight: true, + content: + "Supply chain disruption risk is elevated due to single-source dependencies in 15 agreements.", + }, + { + heading: "Mitigation Strategies", + content: + "Recommend dual-sourcing clauses and quarterly review cycles.", + }, + ], + sources: [ + { name: "Agreement-A.pdf" }, + { name: "Agreement-B.pdf" }, + ], + }, + { + label: "Compliance", + status: "OK", + color: "#16a34a", + sections: [ + { + heading: "Regulatory Alignment", + content: + "All agreements comply with current regulations. Two require updates for 2025 changes.", + }, + ], + sources: [{ name: "Compliance-Report.pdf" }], + }, + ], + }, + ], + }, + { + id: "timeline-chapter", + kicker: "Chapter 3", + title: "Agreement Timeline", + blocks: [ + { + type: "timeline", + legend: [ + { label: "Executed", color: "#16a34a" }, + { label: "Amended", color: "#f59e0b" }, + ], + items: [ + { + date: "Jan 2023", + label: "Master Agreement signed", + side: "executed", + }, + { + date: "Jun 2023", + label: "Amendment 1 — Force Majeure update", + side: "amended", + }, + { + date: "Dec 2023", + label: "Annual renewal executed", + side: "executed", + }, + ], + }, + { + type: "cta", + items: [ + { + label: "Explore Documents", + href: "/documents", + primary: true, + }, + { label: "View Source Data", href: "/data" }, + ], + }, + ], + }, + { + id: "jurisdiction", + kicker: "Chapter 4", + title: "Jurisdiction Map", + blocks: [ + { + type: "map", + mapType: "us", + mode: "categorical", + legend: [ + { label: "Compliant", color: "#0f766e" }, + { label: "Pending", color: "#f59e0b" }, + { label: "Non-compliant", color: "#dc2626" }, + ], + states: [ + { code: "CA", status: "Compliant" }, + { code: "NY", status: "Compliant", count: 247 }, + { code: "TX", status: "Pending", count: 56 }, + { code: "FL", status: "Non-compliant" }, + { code: "IL", status: "Compliant" }, + { code: "OH", status: "Pending" }, + ], + }, + ], + }, + { + id: "case-tracker", + kicker: "Chapter 5", + title: "Case Tracker", + blocks: [ + { + type: "case-history", + title: "SEC v. Meridian Capital Partners LLC", + docket: "No. 22-cv-04817 (S.D.N.Y.)", + status: "Affirmed", + entries: [ + { + courtLevel: "District Court", + courtName: "S.D.N.Y.", + date: "2022-06-10", + action: "Motion for TRO", + outcome: "Granted", + detail: "Court issued TRO freezing defendant assets.", + }, + { + courtLevel: "Court of Appeals", + courtName: "2nd Circuit", + date: "2023-11-08", + action: "Appeal", + outcome: "Affirmed", + }, + { + courtLevel: "Supreme Court", + courtName: "SCOTUS", + date: "2024-03-25", + action: "Certiorari", + outcome: "Cert Denied", + }, + ], + }, + ], + }, + ], +}; + +export interface CamlArticleTestWrapperProps { + document?: CamlDocument; + stats?: Record; +} + +export const CamlArticleTestWrapper: React.FC = ({ + document: doc = SAMPLE_CAML_DOCUMENT, + stats, +}) => { + return ( + +
+ + + +
+
+ ); +}; diff --git a/frontend/tests/CorpusArticleView.ct.tsx b/frontend/tests/CorpusArticleView.ct.tsx new file mode 100644 index 000000000..b3a857374 --- /dev/null +++ b/frontend/tests/CorpusArticleView.ct.tsx @@ -0,0 +1,57 @@ +/** + * Playwright component tests for CorpusArticleView. + * + * Tests cover: + * 1. Empty state when no Readme.CAML exists + * 2. Toolbar with back button and edit button + * 3. Loading state + */ +import { test, expect } from "@playwright/experimental-ct-react"; +import { docScreenshot } from "./utils/docScreenshot"; +import { CorpusArticleViewTestWrapper } from "./CorpusArticleViewTestWrapper"; + +test.describe("CorpusArticleView - No Article", () => { + test("should show empty state when no Readme.CAML exists", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Should show the empty state message + await expect( + page.getByText("No article found for this corpus.") + ).toBeVisible({ timeout: 10000 }); + + // Should show the upload instruction + await expect(page.getByText("Readme.CAML")).toBeVisible(); + + // Back button should be visible + await expect(page.getByText("Back")).toBeVisible(); + + await docScreenshot(page, "caml--article-view--empty-state"); + + await component.unmount(); + }); +}); + +test.describe("CorpusArticleView - With Article", () => { + test("should show back button and toolbar when article exists", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // Toolbar with back button should always be visible + await expect(page.getByText("Back")).toBeVisible({ timeout: 10000 }); + + // Since fetch() for the txtExtractFile URL won't work in tests, + // the view may show loading or error state — but toolbar is always present + await docScreenshot(page, "caml--article-view--toolbar"); + + await component.unmount(); + }); +}); diff --git a/frontend/tests/CorpusArticleViewTestWrapper.tsx b/frontend/tests/CorpusArticleViewTestWrapper.tsx new file mode 100644 index 000000000..8861b5573 --- /dev/null +++ b/frontend/tests/CorpusArticleViewTestWrapper.tsx @@ -0,0 +1,129 @@ +/** + * Test wrapper for CorpusArticleView. + * + * Provides MockedProvider for the GET_CORPUS_ARTICLE query. + * Also intercepts fetch() for the txtExtractFile URL to return + * mock CAML content without a real server. + */ +import React from "react"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { InMemoryCache } from "@apollo/client"; +import { relayStylePagination } from "@apollo/client/utilities"; +import { MemoryRouter } from "react-router-dom"; +import { Provider } from "jotai"; + +import { CorpusArticleView } from "../src/components/corpuses/CorpusHome/CorpusArticleView"; +import { GET_CORPUS_ARTICLE } from "../src/graphql/queries"; +import { CorpusType } from "../src/types/graphql-api"; + +const MOCK_CORPUS: CorpusType = { + id: "Q29ycHVzVHlwZTox", + title: "Supply Chain Analysis", + description: "A corpus of supply chain agreements", + icon: "briefcase", + isPublic: false, + slug: "supply-chain-analysis", + creator: { + id: "user-1", + email: "test@example.com", + slug: "test-user", + }, +} as CorpusType; + +/** Mock: article exists with a txtExtractFile URL */ +const articleExistsMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { + corpusId: MOCK_CORPUS.id, + title: "Readme.CAML", + }, + }, + result: { + data: { + documents: { + edges: [ + { + node: { + id: "doc-readme-1", + title: "Readme.CAML", + txtExtractFile: "/media/test/readme.caml", + modified: "2024-03-15T10:30:00Z", + creator: { + email: "author@example.com", + __typename: "UserType", + }, + __typename: "DocumentType", + }, + __typename: "DocumentTypeEdge", + }, + ], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +/** Mock: no article in corpus */ +const noArticleMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { + corpusId: MOCK_CORPUS.id, + title: "Readme.CAML", + }, + }, + result: { + data: { + documents: { + edges: [], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +function createTestCache() { + return new InMemoryCache({ + typePolicies: { + Query: { + fields: { + documents: relayStylePagination(["inCorpusWithId", "title"]), + }, + }, + DocumentType: { keyFields: ["id"] }, + }, + }); +} + +export interface CorpusArticleViewTestWrapperProps { + hasArticle?: boolean; + corpus?: CorpusType; +} + +export const CorpusArticleViewTestWrapper: React.FC< + CorpusArticleViewTestWrapperProps +> = ({ hasArticle = true, corpus = MOCK_CORPUS }) => { + const mock = hasArticle ? articleExistsMock : noArticleMock; + + return ( + + + + {}} + onEditArticle={() => {}} + testId="test-corpus-article" + /> + + + + ); +}; + +export { MOCK_CORPUS }; diff --git a/frontend/tests/CorpusHomeArticleLanding.ct.tsx b/frontend/tests/CorpusHomeArticleLanding.ct.tsx new file mode 100644 index 000000000..d6b036d5b --- /dev/null +++ b/frontend/tests/CorpusHomeArticleLanding.ct.tsx @@ -0,0 +1,53 @@ +/** + * Playwright component tests for the CAML article landing view. + * + * Tests cover: + * 1. Article view renders as the corpus home when Readme.CAML exists + * (instead of the default landing page) + * 2. Floating controls (chat bar) appear overlaid on the article + * + * NOTE: The full CAML article body will not render because + * CorpusArticleView uses fetch() to load content from txtExtractFile, + * which fails in component tests. The article *container* (toolbar + + * test-id) and the floating controls still render and are verified here. + */ +import { test, expect } from "@playwright/experimental-ct-react"; +import { docScreenshot } from "./utils/docScreenshot"; +import { CorpusHomeArticleLandingTestWrapper } from "./CorpusHomeArticleLandingTestWrapper"; + +test.use({ viewport: { width: 1200, height: 800 } }); + +test.describe("CorpusHome - Article as Landing View", () => { + test("should render article view with floating controls when Readme.CAML exists", async ({ + mount, + page, + }) => { + const component = await mount( + + ); + + // The article container should render (CorpusArticleView, not CorpusLandingView) + await expect(page.getByTestId("corpus-home-article")).toBeVisible({ + timeout: 15000, + }); + + // Article toolbar should have the Back button + await expect(page.getByText("Back")).toBeVisible(); + + // The floating controls should be visible at the bottom + await expect( + page.getByTestId("corpus-article-floating-controls") + ).toBeVisible({ timeout: 5000 }); + + // Chat input should be in the floating bar + await expect(page.getByTestId("corpus-article-chat-input")).toBeVisible(); + + // The landing-specific elements should NOT be present because + // the article view replaced the landing page + await expect(page.getByTestId("corpus-home-landing")).toHaveCount(0); + + await docScreenshot(page, "caml--corpus-home--article-landing"); + + await component.unmount(); + }); +}); diff --git a/frontend/tests/CorpusHomeArticleLandingTestWrapper.tsx b/frontend/tests/CorpusHomeArticleLandingTestWrapper.tsx new file mode 100644 index 000000000..8218681c9 --- /dev/null +++ b/frontend/tests/CorpusHomeArticleLandingTestWrapper.tsx @@ -0,0 +1,175 @@ +/** + * Test wrapper for CorpusHome showing article as landing view. + * + * Mocks the corpus article query to return a Readme.CAML document + * so that CorpusHome renders the CAML article as the default landing + * with floating controls. + */ +import React from "react"; +import { MockedProvider, MockedResponse } from "@apollo/client/testing"; +import { InMemoryCache } from "@apollo/client"; +import { relayStylePagination } from "@apollo/client/utilities"; +import { MemoryRouter } from "react-router-dom"; +import { Provider } from "jotai"; + +import { CorpusHome } from "../src/components/corpuses/CorpusHome"; +import { + GET_CORPUS_ARTICLE, + GET_CORPUS_WITH_HISTORY, +} from "../src/graphql/queries"; +import { CorpusType } from "../src/types/graphql-api"; +import { corpusDetailView } from "../src/graphql/cache"; + +const MOCK_CORPUS: CorpusType = { + id: "Q29ycHVzVHlwZTox", + title: "Supply Chain Analysis", + description: "A corpus of supply chain agreements", + icon: "", + isPublic: false, + slug: "supply-chain-analysis", + creator: { + id: "user-1", + email: "test@example.com", + slug: "test-user", + }, +} as CorpusType; + +/** Mock: article exists with a txtExtractFile URL */ +const articleExistsMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { corpusId: MOCK_CORPUS.id, title: "Readme.CAML" }, + }, + result: { + data: { + documents: { + edges: [ + { + node: { + id: "doc-readme-1", + title: "Readme.CAML", + txtExtractFile: "blob:caml-content", + modified: "2024-03-15T10:30:00Z", + creator: { + email: "author@example.com", + __typename: "UserType", + }, + __typename: "DocumentType", + }, + __typename: "DocumentTypeEdge", + }, + ], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +/** Mock: no article exists */ +const articleEmptyMock: MockedResponse = { + request: { + query: GET_CORPUS_ARTICLE, + variables: { corpusId: MOCK_CORPUS.id, title: "Readme.CAML" }, + }, + result: { + data: { + documents: { + edges: [], + __typename: "DocumentTypeConnection", + }, + }, + }, +}; + +/** Mock: corpus with history (minimal, needed if CorpusLandingView renders) */ +const corpusWithHistoryMock: MockedResponse = { + request: { + query: GET_CORPUS_WITH_HISTORY, + variables: { id: MOCK_CORPUS.id }, + }, + result: { + data: { + corpus: { + id: MOCK_CORPUS.id, + slug: "supply-chain-analysis", + title: "Supply Chain Analysis", + description: "A corpus of supply chain agreements", + mdDescription: null, + icon: "", + created: "2024-01-01T00:00:00Z", + modified: "2024-03-15T10:30:00Z", + isPublic: false, + myPermissions: [], + documentCount: 42, + license: "", + licenseLink: "", + creator: { + id: "user-1", + email: "test@example.com", + slug: "test-user", + __typename: "UserType", + }, + labelSet: null, + descriptionRevisions: [], + __typename: "CorpusType", + }, + }, + }, +}; + +function createTestCache() { + return new InMemoryCache({ + typePolicies: { + Query: { + fields: { + documents: relayStylePagination(["inCorpusWithId", "title"]), + }, + }, + DocumentType: { keyFields: ["id"] }, + CorpusType: { keyFields: ["id"] }, + }, + }); +} + +export interface CorpusHomeArticleLandingTestWrapperProps { + hasArticle?: boolean; +} + +export const CorpusHomeArticleLandingTestWrapper: React.FC< + CorpusHomeArticleLandingTestWrapperProps +> = ({ hasArticle = true }) => { + // Ensure we start on the landing view + corpusDetailView("landing"); + + const articleMock = hasArticle ? articleExistsMock : articleEmptyMock; + + // Duplicate mocks for potential refetches (cache-and-network policy) + const mocks: MockedResponse[] = [ + articleMock, + { ...articleMock }, + corpusWithHistoryMock, + { ...corpusWithHistoryMock }, + ]; + + return ( + + + + {}} + onEditArticle={() => {}} + stats={{ + totalDocs: 42, + totalAnnotations: 1280, + totalAnalyses: 5, + totalExtracts: 3, + totalThreads: 12, + }} + statsLoading={false} + /> + + + + ); +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 282ccab69..6e2ff3950 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -137,6 +137,12 @@ export default defineConfig({ setupFiles: "./src/setupTests.ts", css: true, reporters: ["verbose"], + deps: { + // @os-legal/caml-react uses styled-components template literals in its + // ESM bundle. Vitest's jsdom environment needs these inlined so the + // styled-components CJS interop resolves correctly. + inline: ["@os-legal/caml-react"], + }, // More specific include pattern include: ["src/**/*.test.{ts,tsx}"], // Explicitly exclude Playwright directories and node_modules diff --git a/frontend/yarn.lock b/frontend/yarn.lock index df0fd10b6..ca36e4e2a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1282,6 +1282,18 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@os-legal/caml-react@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@os-legal/caml-react/-/caml-react-0.0.1.tgz#ac34ed632462ab6f216fa5d7967da4ed51f9d654" + integrity sha512-BHgahn9cvsXs197iQt3a0qMk/zpcA9PuJ5W6pF4303115TWTKiXqr7ocW3OHaVv97oipXg6EMdrMQiRjES8YKA== + dependencies: + "@os-legal/caml" "workspace:*" + +"@os-legal/caml@^0.0.1", "@os-legal/caml@workspace:*": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@os-legal/caml/-/caml-0.0.1.tgz#e3fc3005bee4ea6f07a162c01a1eba0119162b4b" + integrity sha512-vfDUYndR/AtOVnoNhd0uc49NWz90WjPZfZpaDw0C4XFChdWTVnjANp9HlSQOlkw6taQ1sjWVMuPH+tzkEuLPNQ== + "@os-legal/ui@0.1.16": version "0.1.16" resolved "https://registry.yarnpkg.com/@os-legal/ui/-/ui-0.1.16.tgz#e417df07eb08a7ed793987e4a62d08167d95bc68" diff --git a/opencontractserver/constants/document_processing.py b/opencontractserver/constants/document_processing.py index e420d2366..62638db1d 100644 --- a/opencontractserver/constants/document_processing.py +++ b/opencontractserver/constants/document_processing.py @@ -6,6 +6,10 @@ AppRegistryNotReady errors during settings loading. """ +# File types that are stored as txt_extract_file (plain text, no parsing needed). +# Shared between versioning.py and corpus models.py — single source of truth. +TEXT_MIMETYPES = {"text/plain", "text/markdown", "application/txt"} + # Maximum file upload size in bytes (5 GB). # Used by Django's DATA_UPLOAD_MAX_MEMORY_SIZE setting. MAX_FILE_UPLOAD_SIZE_BYTES = 5_242_880_000 diff --git a/opencontractserver/corpuses/models.py b/opencontractserver/corpuses/models.py index e9a8df41f..81ffd1b1d 100644 --- a/opencontractserver/corpuses/models.py +++ b/opencontractserver/corpuses/models.py @@ -800,9 +800,6 @@ def add_document( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", } - # File types that are stored as-is without parsing - TEXT_MIMETYPES = {"text/plain", "application/txt"} - def import_content( self, content: bytes, diff --git a/opencontractserver/documents/versioning.py b/opencontractserver/documents/versioning.py index 863cbbd66..becbb525d 100644 --- a/opencontractserver/documents/versioning.py +++ b/opencontractserver/documents/versioning.py @@ -36,6 +36,7 @@ from django.core.files.base import ContentFile from django.db import transaction +from opencontractserver.constants.document_processing import TEXT_MIMETYPES from opencontractserver.corpuses.models import Corpus, CorpusFolder from opencontractserver.documents.models import Document, DocumentPath @@ -50,11 +51,9 @@ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "text/plain": ".txt", + "text/markdown": ".md", } -# File types that are stored as txt_extract_file (plain text, no parsing needed) -TEXT_MIMETYPES = {"text/plain", "application/txt"} - def _create_content_file( content: bytes, diff --git a/opencontractserver/pipeline/base/file_types.py b/opencontractserver/pipeline/base/file_types.py index c9205f2a0..eb43fbc2e 100644 --- a/opencontractserver/pipeline/base/file_types.py +++ b/opencontractserver/pipeline/base/file_types.py @@ -5,6 +5,7 @@ MIME_TO_FILE_TYPE: dict[str, str] = { "application/pdf": "pdf", "text/plain": "txt", + "text/markdown": "md", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", } @@ -15,6 +16,7 @@ FILE_TYPE_LABELS: dict[str, str] = { "pdf": "PDF", "txt": "Plain Text", + "md": "Markdown", "docx": "Word Document", } @@ -33,9 +35,8 @@ class FileTypeEnum(str, Enum): PDF = "pdf" TXT = "txt" + MD = "md" DOCX = "docx" - # HTML = "html" # Removed as we don't support it - # Add more as needed @classmethod def from_mimetype(cls, mimetype: str) -> "FileTypeEnum | None": diff --git a/opencontractserver/pipeline/parsers/oc_markdown_parser.py b/opencontractserver/pipeline/parsers/oc_markdown_parser.py new file mode 100644 index 000000000..64dc95cda --- /dev/null +++ b/opencontractserver/pipeline/parsers/oc_markdown_parser.py @@ -0,0 +1,57 @@ +import logging +from typing import Optional + +from django.core.files.storage import default_storage + +from opencontractserver.documents.models import Document +from opencontractserver.pipeline.base.file_types import FileTypeEnum +from opencontractserver.pipeline.base.parser import BaseParser +from opencontractserver.types.dicts import OpenContractDocExport + +logger = logging.getLogger(__name__) + + +class MarkdownParser(BaseParser): + """ + No-op parser for Markdown and CAML files. + + Stores the raw text content without creating structural annotations. + Used for corpus article files (Readme.CAML) and other markdown documents + that should be rendered by the frontend, not processed by the NLP pipeline. + """ + + title = "Markdown Parser" + description = "Stores markdown/CAML files without NLP processing." + author = "OpenContracts" + dependencies = [] + supported_file_types = [FileTypeEnum.MD] + + def _parse_document_impl( + self, user_id: int, doc_id: int, **all_kwargs + ) -> Optional[OpenContractDocExport]: + logger.info( + f"MarkdownParser - Storing doc {doc_id} for user {user_id} (no-op parse)" + ) + + document = Document.objects.get(pk=doc_id) + + if not document.txt_extract_file.name: + logger.error(f"No txt file found for document {doc_id}") + return None + + txt_path = document.txt_extract_file.name + with default_storage.open(txt_path, mode="r") as txt_file: + # Storage backends may not support encoding= kwarg, so decode + # the bytes explicitly to handle non-ASCII content safely. + raw = txt_file.read() + text_content = raw.decode("utf-8") if isinstance(raw, bytes) else raw + + return { + "title": document.title, + "content": text_content, + "description": document.description or "", + "pawls_file_content": [], + "page_count": 1, + "doc_labels": [], + "labelled_text": [], + } diff --git a/opencontractserver/tests/test_markdown_parser.py b/opencontractserver/tests/test_markdown_parser.py new file mode 100644 index 000000000..dfa960b6a --- /dev/null +++ b/opencontractserver/tests/test_markdown_parser.py @@ -0,0 +1,105 @@ +"""Tests for the MarkdownParser (no-op parser for .md / .caml files).""" + +from io import BytesIO, StringIO +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.test import TestCase + +from opencontractserver.documents.models import Document +from opencontractserver.pipeline.parsers.oc_markdown_parser import MarkdownParser + +User = get_user_model() + + +class TestMarkdownParser(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = User.objects.create_user( + username="md_parser_test_user", password="testpass" + ) + cls.parser = MarkdownParser() + + def _make_document(self, txt_content: str = "# Hello\nWorld") -> Document: + doc = Document.objects.create( + title="test.md", + description="A test markdown document", + creator=self.user, + ) + doc.txt_extract_file.save("test.txt", ContentFile(txt_content.encode("utf-8"))) + doc.save() + return doc + + def test_parse_returns_expected_dict(self): + """Successful parse returns title, content, and empty annotation lists.""" + doc = self._make_document("# My Article\nSome body text.") + result = self.parser._parse_document_impl(self.user.id, doc.id) + + self.assertIsNotNone(result) + self.assertEqual(result["title"], "test.md") + self.assertEqual(result["content"], "# My Article\nSome body text.") + self.assertEqual(result["description"], "A test markdown document") + self.assertEqual(result["pawls_file_content"], []) + self.assertEqual(result["page_count"], 1) + self.assertEqual(result["doc_labels"], []) + self.assertEqual(result["labelled_text"], []) + + def test_parse_no_txt_file_returns_none(self): + """Returns None when document has no txt_extract_file.""" + doc = Document.objects.create( + title="empty.md", + description="", + creator=self.user, + ) + result = self.parser._parse_document_impl(self.user.id, doc.id) + self.assertIsNone(result) + + def test_parse_handles_bytes_from_storage(self): + """Storage backends may return bytes; parser should decode them.""" + doc = self._make_document("Unicode content: \u00e9\u00e0\u00fc") + + # Mock storage to return raw bytes instead of str + raw_bytes = "Unicode content: \u00e9\u00e0\u00fc".encode() + with patch( + "opencontractserver.pipeline.parsers.oc_markdown_parser.default_storage" + ) as mock_storage: + mock_storage.open.return_value.__enter__ = lambda s: BytesIO(raw_bytes) + mock_storage.open.return_value.__exit__ = lambda s, *a: None + result = self.parser._parse_document_impl(self.user.id, doc.id) + + self.assertIsNotNone(result) + self.assertEqual(result["content"], "Unicode content: \u00e9\u00e0\u00fc") + + def test_parse_handles_string_from_storage(self): + """Storage backends may return str directly; parser should handle both.""" + doc = self._make_document("Plain string content") + + with patch( + "opencontractserver.pipeline.parsers.oc_markdown_parser.default_storage" + ) as mock_storage: + mock_storage.open.return_value.__enter__ = lambda s: StringIO( + "Plain string content" + ) + mock_storage.open.return_value.__exit__ = lambda s, *a: None + result = self.parser._parse_document_impl(self.user.id, doc.id) + + self.assertIsNotNone(result) + self.assertEqual(result["content"], "Plain string content") + + def test_parse_empty_description_defaults_to_empty_string(self): + """When document.description is None, result uses empty string.""" + doc = Document.objects.create( + title="no-desc.md", + creator=self.user, + ) + doc.txt_extract_file.save("no-desc.txt", ContentFile(b"content")) + doc.save() + # description may be None or "" depending on model field + doc.description = None + doc.save() + + result = self.parser._parse_document_impl(self.user.id, doc.id) + self.assertIsNotNone(result) + self.assertEqual(result["description"], "")