Files
openwork/ee/apps/landing/lib/render-markdown.tsx
Jan Carbonell 6cfe7fd2b9 docs(landing): add privacy policy and terms of use pages (#1359)
* docs(landing): add privacy policy and terms of use pages

- Add comprehensive privacy policy covering Desktop App (no tracking),
  Cloud Service (operational telemetry only), and Website (PostHog analytics)
- Add terms of use with AI output disclaimer, export controls, beta features,
  feedback, publicity rights, and JAMS arbitration
- Create reusable LegalPage component and shared parser for .txt legal docs
- Add Privacy and Terms links to site footer
- Both pages render with consistent styling, bold definition terms,
  subheadings for tracking technology categories, and clickable email links

* chore: add .turbo to .gitignore

* fix(landing): improve legal page parser for terms readability

- Detect definition blocks ("Term" means ...) and render as bulleted
  list with bold terms
- Bold ALL CAPS text (warranty disclaimers, arbitration notices, etc.)
- Make URLs clickable links alongside emails
- Distinguish h2 headings (questions, longer titles) from h3 subheadings
  (short section names like "Our IP", "Billing", "Usage Data")

* fix(landing): improve subscription termination wording and URL parsing

- Clarify recurring billing cancellation: users can cancel via account
  settings first, contact email as fallback at our discretion
- Fix URL regex to avoid capturing trailing periods/punctuation

* fix(landing): clarify subscription cancellation — email always accepted, late refunds at our discretion

* docs(landing): convert privacy policy and terms of use to markdown

- Add proper heading hierarchy (# h1, ## h2, ### h3)
- Bold definition terms, ALL CAPS legal clauses, and sub-processor names
- Convert restriction items into bullet points
- Make all URLs and emails clickable markdown links
- Use blockquote for the API key disclaimer note
- Structure tracking technologies as proper subheadings

* refactor(landing): use markdown for legal pages, no new dependencies

- Rename .txt to .md with proper markdown formatting
- Replace txt parser with lean render-markdown.tsx (zero deps, ~120 lines)
- LegalPage handles full page shell — page.tsx files are now 8 lines each
- Add legal-prose CSS for consistent typography
- Delete parse-legal-doc.tsx
2026-04-05 01:15:17 -06:00

160 lines
4.0 KiB
TypeScript

import { ReactNode } from "react";
/**
* Minimal markdown-to-JSX renderer for legal pages.
* Supports: headings, bold, links, lists, blockquotes, horizontal rules, paragraphs.
* No external dependencies.
*/
/** Render inline markdown (bold, links) within a text string. */
function renderInline(text: string): ReactNode {
// Split on **bold**, [text](url), and bare URLs
const parts: ReactNode[] = [];
const regex =
/(\*\*(.+?)\*\*)|(\[([^\]]+)\]\(([^)]+)\))|(https?:\/\/[^\s,)<>]+[^\s,)<>.;:])|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// **bold**
parts.push(
<strong key={match.index} className="font-semibold text-[#011627]">
{match[2]}
</strong>
);
} else if (match[3]) {
// [text](url)
const href = match[5];
const isExternal = href.startsWith("http");
parts.push(
<a
key={match.index}
href={href}
{...(isExternal ? { target: "_blank", rel: "noreferrer" } : {})}
>
{match[4]}
</a>
);
} else if (match[6]) {
// bare URL
parts.push(
<a key={match.index} href={match[6]} target="_blank" rel="noreferrer">
{match[6]}
</a>
);
} else if (match[7]) {
// email
parts.push(
<a key={match.index} href={`mailto:${match[7]}`}>
{match[7]}
</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length === 1 ? parts[0] : parts;
}
/** Parse a markdown string and return React elements. */
export function renderMarkdown(md: string): ReactNode {
const lines = md.split("\n");
const elements: ReactNode[] = [];
let i = 0;
let key = 0;
while (i < lines.length) {
const line = lines[i];
// Blank line — skip
if (line.trim() === "") {
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
if (level === 1) {
elements.push(<h1 key={key++}>{renderInline(text)}</h1>);
} else if (level === 2) {
elements.push(<h2 key={key++}>{renderInline(text)}</h2>);
} else {
elements.push(<h3 key={key++}>{renderInline(text)}</h3>);
}
i++;
continue;
}
// Horizontal rule
if (/^---+$/.test(line.trim())) {
elements.push(<hr key={key++} />);
i++;
continue;
}
// Blockquote — collect consecutive > lines
if (line.startsWith(">")) {
const bqLines: string[] = [];
while (i < lines.length && lines[i].startsWith(">")) {
bqLines.push(lines[i].replace(/^>\s?/, ""));
i++;
}
elements.push(
<blockquote key={key++}>
{renderMarkdown(bqLines.join("\n"))}
</blockquote>
);
continue;
}
// List — collect consecutive - lines
if (line.match(/^\s*-\s/)) {
const items: string[] = [];
while (i < lines.length && lines[i].match(/^\s*-\s/)) {
items.push(lines[i].replace(/^\s*-\s/, ""));
i++;
}
elements.push(
<ul key={key++}>
{items.map((item, j) => (
<li key={j}>{renderInline(item)}</li>
))}
</ul>
);
continue;
}
// Paragraph — collect consecutive non-blank, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^#{1,3}\s/) &&
!lines[i].match(/^\s*-\s/) &&
!lines[i].startsWith(">") &&
!/^---+$/.test(lines[i].trim())
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
elements.push(<p key={key++}>{renderInline(paraLines.join(" "))}</p>);
}
}
return elements;
}