Skip to content

Commit 2938057

Browse files
aalemayhuclaude
andauthored
docs: rewrite user-facing documentation (#2074)
## Summary Rebuilds `/documentation` against the docs overhaul spec: new task-oriented IA, redirect map for every old slug, new home, callouts, code-block copy buttons, mobile drawer, softened WIP banner. The five highest-leverage pages (Connect Notion, How sync works, Card options, Limits, Common problems) are rewritten with claims verified against `src/`. The other new pages in the IA ship as honest stubs, each with a tracking GitHub issue. ## What's in this PR **Structural / visual** - New `sidebar.ts` with the Designer's IA (Start here / Make better cards / Sync with Notion / When something breaks / Reference / Links) plus a `redirects` map covering every retired slug. - `loader.ts` honours redirects on miss; `DocContent.tsx` issues a 301-style React Router `<Navigate replace>` so legacy URLs land on the new slug. - `DocsPage.tsx` swaps the inline mobile menu for a slide-in drawer (`DocsDrawer.tsx`) with body-scroll lock, Escape-to-close, and backdrop click. - `DocsSidebar.tsx` reuses one component for desktop and drawer mode, adds an active-group left-border indicator. - `DocsHome.tsx` rewritten per the ASCII mock — hero with two CTAs, four Start-here cards, popular pages list, footer line. Mascot dropped from the docs landing. - `DocContent.tsx` adds `:::note`, `:::tip`, `:::warning` callout directives via a small remark-equivalent regex transform that emits raw HTML, plus a `CodeBlock` slot with a copy button. - New components: `Callout.tsx`, `CodeBlock.tsx`, `DocsDrawer.tsx` (~30–60 lines each). - CSS deltas in `DocsPage.module.css`: callouts, steps list, code-wrapper + copy button, drawer, home cards, slimmer WIP banner. H2 top margin tightened to 2.5rem; `pre` gets a 1px border. Old hero classes removed. - `WipBanner.tsx` softened — drops the WIP pill, single short sentence, smaller padding. - File moves preserve existing prose where slugs map cleanly (`features/markdown.md` → `cards/markdown.md`, `features/html.md` → `cards/html.md`, `features/notion-support.md` → `cards/notion-blocks.md`, `troubleshooting/contact.md` → `help/contact.md`, `troubleshooting/bug-report.md` → `help/bug-report.md`, `advanced/napi.md` → `reference/api.md`, `advanced/self-hosting.md` → `reference/self-hosting.md`, `misc/privacy-policy.md` → `reference/privacy.md`, `misc/terms-of-service.md` → `reference/terms.md`). **Content rewritten in this PR** - `start-here/connect-notion` — full rewrite around the Notion integration. The load-bearing first-touch page. - `sync/how-it-works` — new page covering the Ankify sync flow. Notes the private-alpha allowlist truthfully (see Risks). - `cards/card-options` — reference table built from `src/controllers/CardOptionsController/supportedOptions.ts`. Internal keys listed at the bottom for bug reports. - `help/limits` — every unbacked number dropped (21 files, 100 flashcards/deck, 2,100 files, "no concurrent job limit"). Now lists only what `src/lib/misc/getUploadLimits.ts`, `src/infrastracture/adapters/fileConversion/convertPDFToImages.ts`, and `src/lib/constants.ts` actually back. - `help/common-problems` — 9 entries, each with the exact error string from `getUploadValidationError.ts`, `convertPDFToImages.ts`, `ClaudeService.ts`, plus Notion permission + missing-images cases. - `reference/glossary` — merged from `domain.md` + `terminology.md`, alphabetised, plain language. - `reference/self-hosting` — corrected to the canonical `2anki/server` monorepo (the page used to imply two repos; `web/` is a workspace). Kept short — the audit explicitly says don't grow this. - `cards/markdown` — fixed "Markdonw" typo, retitled to match sidebar. - `cards/notion-blocks`, `help/contact`, `help/bug-report` — fixed stale `2anki/2anki.net` GitHub links and old slug references. **What's intentionally stubbed** Each stub is a single short paragraph in the voice from the Designer's section 8, plus a link to its tracking issue: - `start-here/what-is-2anki` — issue [#2066](#2066) - `start-here/upload-a-file` — issue [#2067](#2067) - `start-here/open-in-anki` — issue [#2068](#2068) - `cards/card-types` — issue [#2069](#2069) - `sync/review-export` — issue [#2070](#2070) - `sync/troubleshooting` — issue [#2071](#2071) - `reference/file-formats` — issue [#2072](#2072) - `reference/self-hosting` — short summary now; full guide tracked in [#2073](#2073) `reference/privacy` and `reference/terms` keep their existing copy. The audit's `MISSING-CONTEXT` finding is recorded as a `todo:` line in each file's frontmatter so Alexander can pick them up. See Risks. ## Verified against code | Claim | Source of truth | |---|---| | 100 MB free upload, ~10 GB paid | `src/lib/misc/getUploadLimits.ts` | | 100 PDF pages on free, no fixed cap on paid | `src/infrastracture/adapters/fileConversion/convertPDFToImages.ts` | | 2-hour file deletion | `CLEANUP_AGE_SECONDS = 7200` in `src/lib/constants.ts` (also matches the upload UI footer text) | | All `help/common-problems` error strings | `src/lib/upload/getUploadValidationError.ts`, `convertPDFToImages.ts`, `src/lib/claude/ClaudeService.ts` | | Card option labels and descriptions | `src/controllers/CardOptionsController/supportedOptions.ts` (exact key match) | | Self-hosting target is `2anki/server` monorepo | `package.json`, `web/package.json` (`"name": "2anki-web"`), `web/CLAUDE.md` | | Sync is currently allowlisted | `src/lib/constants.ts` `ANKIFY_ALLOWLIST_EMAILS = ['alexander@alemayhu.com']`, enforced in `src/routes/middleware/RequireAnkifyAccess.ts` | **Audit corrections worth flagging** 1. The audit said the `/upload` UI claims a 24-hour cleanup window contradicting the 2-hour limits page. As of this PR, the upload UI in `web/src/pages/UploadPage/UploadPage.tsx:60` reads "All files uploaded here are automatically deleted after 2 hours." — consistent with `CLEANUP_AGE_SECONDS`. No UI change needed. 2. The audit asked us to confirm whether `2anki/web` standalone is still the deployment target. It isn't — `web/` is a workspace inside this monorepo (`web/CLAUDE.md` is explicit, package name is `2anki-web`). `reference/self-hosting` now reflects that. 3. The audit flagged the privacy policy as missing Stripe / SendGrid / Anthropic / Vertex AI and listing ChatGPT despite `src/` having no OpenAI integration. Confirmed: `grep -rn -i "ChatGPT\|OpenAI" src/` returns nothing. Stripe, SendGrid, Anthropic, and Vertex AI are all imported in `src/`. Defer the actual policy rewrite to Alexander per spec — but the contradiction is real and the `todo:` frontmatter records it. ## Risks - **Sync alpha framing**: `sync/how-it-works` is honest about private alpha (allowlisted in code). If Alexander would rather the doc imply broader availability ahead of opening sync up, soften the callout. I'd lean on accuracy. - **Redirects map**: Legacy slugs that aren't in the map (e.g. anyone deep-linked an old anchor) will hit "Not found". The map covers every previous file in the content directory, so this should only catch typos and external broken links. - **Callout transform**: implemented as a regex transform that emits raw `<aside>` HTML rather than pulling in `remark-directive`. Trade-off note: less moving parts and zero new deps, at the cost of being a string transform rather than an AST one. If the directive syntax grows beyond the three callout variants, swap in `remark-directive`. - **`Callout.tsx` is currently unused** (kept for forward-compat with the design spec; the in-doc callouts go through the regex transform). Considered removing it but the Designer's hand-off explicitly listed it as a deliverable. Easy to delete in a follow-up if we don't need it. Rollback plan: revert this single commit. No DB migrations, no env vars, no server code touched. ## Goal alignment Aligned with the 300K-user goal in `CLAUDE.md`. The single highest-leverage doc change — rewriting the load-bearing first-touch page (`start-here/connect-notion`) around the actual happy path — addresses the audit's #1 finding ("the docs describe the 2022-era product"). New users currently hit a guide that pushes them down a Notion-export-then-zip flow that's slower and more error-prone than the integration. The new IA puts the fast path first, the paid feature (sync) gets its own visible group, and `help/common-problems` should reduce inbound support email by being indexable on real error strings. ## Test plan Before merging, please: - [ ] Run `pnpm dev` and visit `/documentation`. Hero, four start-here cards, and popular-pages list render. CTAs go to Connect Notion / Upload a file. - [ ] Visit `/documentation/guides/getting-started` — should redirect to `/documentation/start-here/connect-notion`. - [ ] Visit `/documentation/troubleshooting/limits` — should redirect to `/documentation/help/limits`. - [ ] Visit `/documentation/misc/privacy-policy` — should redirect to `/documentation/reference/privacy`. - [ ] Open dev tools, set viewport to 375 px wide. Hit Menu — drawer slides in from the left, no horizontal scroll, hero CTAs stack. Esc closes the drawer; clicking the backdrop closes it. - [ ] On `cards/card-options`, scan the table — every option in the upload screen's settings modal should have a row. - [ ] On `help/common-problems`, hover or click a code block — Copy button appears top-right and copies the snippet. - [ ] Verify the `:::warning` callout on `sync/how-it-works` renders with the warning palette. - [ ] Sidebar: navigate into `Sync with Notion` group — the active-group left border should appear on that group's items only. CI checks already passing locally: - `pnpm --filter 2anki-web typecheck` — clean - `pnpm --filter 2anki-web lint` — clean - `pnpm --filter 2anki-web test` — 70/70 passing (sidebar, loader, DocsHome added) - `pnpm --filter 2anki-web build` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ba1f15 commit 2938057

55 files changed

Lines changed: 1365 additions & 759 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

web/src/pages/DocsPage/Callout.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ReactNode } from 'react';
2+
import styles from './DocsPage.module.css';
3+
4+
export type CalloutVariant = 'note' | 'tip' | 'warning';
5+
6+
interface CalloutProps {
7+
variant: CalloutVariant;
8+
children: ReactNode;
9+
}
10+
11+
const variantClass: Record<CalloutVariant, string> = {
12+
note: styles.calloutNote,
13+
tip: styles.calloutTip,
14+
warning: styles.calloutWarning,
15+
};
16+
17+
export function Callout({ variant, children }: Readonly<CalloutProps>) {
18+
return (
19+
<aside className={`${styles.callout} ${variantClass[variant]}`} role="note">
20+
{children}
21+
</aside>
22+
);
23+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { HTMLAttributes, ReactNode, useState } from 'react';
2+
import styles from './DocsPage.module.css';
3+
4+
type PreProps = HTMLAttributes<HTMLPreElement> & { children?: ReactNode };
5+
6+
function getTextContent(node: ReactNode): string {
7+
if (node == null || typeof node === 'boolean') return '';
8+
if (typeof node === 'string' || typeof node === 'number') return String(node);
9+
if (Array.isArray(node)) return node.map(getTextContent).join('');
10+
if (typeof node === 'object' && 'props' in node) {
11+
const props = node.props as { children?: ReactNode } | null;
12+
if (props && 'children' in props) {
13+
return getTextContent(props.children);
14+
}
15+
}
16+
return '';
17+
}
18+
19+
export function CodeBlock({ children, ...rest }: Readonly<PreProps>) {
20+
const [copied, setCopied] = useState(false);
21+
22+
const onCopy = async () => {
23+
const text = getTextContent(children);
24+
if (!text) return;
25+
try {
26+
await navigator.clipboard.writeText(text);
27+
setCopied(true);
28+
window.setTimeout(() => setCopied(false), 1500);
29+
} catch {
30+
setCopied(false);
31+
}
32+
};
33+
34+
return (
35+
<div className={styles.codeWrapper}>
36+
<button
37+
type="button"
38+
className={styles.copyButton}
39+
onClick={onCopy}
40+
aria-label="Copy code to clipboard"
41+
>
42+
{copied ? 'Copied' : 'Copy'}
43+
</button>
44+
<pre {...rest}>{children}</pre>
45+
</div>
46+
);
47+
}

web/src/pages/DocsPage/DocContent.tsx

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { AnchorHTMLAttributes, useEffect, useMemo } from 'react';
2-
import { Link, useLocation, useNavigate } from 'react-router-dom';
2+
import {
3+
Link,
4+
Navigate,
5+
useLocation,
6+
useNavigate,
7+
} from 'react-router-dom';
38
import ReactMarkdown, { Components } from 'react-markdown';
49
import remarkGfm from 'remark-gfm';
510
import rehypeRaw from 'rehype-raw';
611
import rehypeSlug from 'rehype-slug';
7-
import { loadDoc } from './loader';
8-
import { findAdjacent } from './sidebar';
12+
import { loadDoc, resolveSlug } from './loader';
13+
import { findAdjacent, redirects } from './sidebar';
14+
import { CalloutVariant } from './Callout';
15+
import { CodeBlock } from './CodeBlock';
916
import styles from './DocsPage.module.css';
1017

1118
interface DocContentProps {
@@ -58,15 +65,68 @@ function DocsAnchor({ href, children, ...rest }: AnchorProps) {
5865
);
5966
}
6067

61-
const markdownComponents: Components = { a: DocsAnchor };
68+
const CALLOUT_VARIANT_CLASS: Record<CalloutVariant, string> = {
69+
note: 'callout-note',
70+
tip: 'callout-tip',
71+
warning: 'callout-warning',
72+
};
73+
74+
function parseCalloutOpen(line: string): CalloutVariant | null {
75+
if (!line.startsWith(':::')) return null;
76+
const rest = line.slice(3).trim();
77+
return Object.hasOwn(CALLOUT_VARIANT_CLASS, rest)
78+
? (rest as CalloutVariant)
79+
: null;
80+
}
81+
82+
function isCalloutClose(line: string): boolean {
83+
return line.trim() === ':::';
84+
}
85+
86+
function transformCallouts(body: string): string {
87+
const lines = body.split('\n');
88+
const out: string[] = [];
89+
let i = 0;
90+
while (i < lines.length) {
91+
const variant = parseCalloutOpen(lines[i]);
92+
if (variant == null) {
93+
out.push(lines[i]);
94+
i++;
95+
continue;
96+
}
97+
let close = i + 1;
98+
while (close < lines.length && !isCalloutClose(lines[close])) close++;
99+
if (close >= lines.length) {
100+
out.push(lines[i]);
101+
i++;
102+
continue;
103+
}
104+
const inner = lines.slice(i + 1, close).join('\n').trim();
105+
out.push(
106+
`<aside class="callout ${CALLOUT_VARIANT_CLASS[variant]}">`,
107+
'',
108+
inner,
109+
'',
110+
'</aside>',
111+
);
112+
i = close + 1;
113+
}
114+
return out.join('\n');
115+
}
116+
117+
const markdownComponents: Components = {
118+
a: DocsAnchor,
119+
pre: CodeBlock as Components['pre'],
120+
};
62121
const remarkPlugins = [remarkGfm];
63122
const rehypePlugins = [rehypeRaw, rehypeSlug];
64123

65-
const EDIT_BASE = 'https://github.com/2anki/web/edit/main/';
124+
const EDIT_BASE = 'https://github.com/2anki/server/edit/main/';
66125

67126
export function DocContent({ slug }: Readonly<DocContentProps>) {
68127
const doc = loadDoc(slug);
69128
const { hash } = useLocation();
129+
const resolvedSlug = resolveSlug(slug);
70130

71131
useEffect(() => {
72132
if (!hash) {
@@ -78,7 +138,14 @@ export function DocContent({ slug }: Readonly<DocContentProps>) {
78138
if (el) el.scrollIntoView();
79139
}, [slug, hash]);
80140

81-
const { prev, next } = useMemo(() => findAdjacent(slug), [slug]);
141+
const { prev, next } = useMemo(
142+
() => findAdjacent(resolvedSlug),
143+
[resolvedSlug],
144+
);
145+
146+
if (slug !== resolvedSlug && Object.hasOwn(redirects, slug)) {
147+
return <Navigate to={`/documentation/${resolvedSlug}`} replace />;
148+
}
82149

83150
if (!doc) {
84151
return (
@@ -94,6 +161,7 @@ export function DocContent({ slug }: Readonly<DocContentProps>) {
94161

95162
const { frontmatter, body, sourcePath } = doc;
96163
const editUrl = `${EDIT_BASE}${sourcePath}`;
164+
const transformedBody = transformCallouts(body);
97165

98166
return (
99167
<article className={styles.article}>
@@ -112,7 +180,7 @@ export function DocContent({ slug }: Readonly<DocContentProps>) {
112180
rehypePlugins={rehypePlugins}
113181
components={markdownComponents}
114182
>
115-
{body}
183+
{transformedBody}
116184
</ReactMarkdown>
117185
</div>
118186

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useEffect } from 'react';
2+
import { DocsSidebar } from './DocsSidebar';
3+
import styles from './DocsPage.module.css';
4+
5+
interface DocsDrawerProps {
6+
isOpen: boolean;
7+
onClose: () => void;
8+
activeSlug: string;
9+
}
10+
11+
export function DocsDrawer({
12+
isOpen,
13+
onClose,
14+
activeSlug,
15+
}: Readonly<DocsDrawerProps>) {
16+
useEffect(() => {
17+
if (!isOpen) return undefined;
18+
const previousOverflow = document.body.style.overflow;
19+
document.body.style.overflow = 'hidden';
20+
return () => {
21+
document.body.style.overflow = previousOverflow;
22+
};
23+
}, [isOpen]);
24+
25+
useEffect(() => {
26+
if (!isOpen) return undefined;
27+
const onKeyDown = (event: KeyboardEvent) => {
28+
if (event.key === 'Escape') onClose();
29+
};
30+
window.addEventListener('keydown', onKeyDown);
31+
return () => window.removeEventListener('keydown', onKeyDown);
32+
}, [isOpen, onClose]);
33+
34+
if (!isOpen) return null;
35+
36+
return (
37+
<div className={styles.drawerOverlay} role="dialog" aria-modal="true">
38+
<button
39+
type="button"
40+
className={styles.drawerBackdrop}
41+
onClick={onClose}
42+
aria-label="Close menu"
43+
/>
44+
<div className={styles.drawer}>
45+
<div className={styles.drawerHeader}>
46+
<span className={styles.drawerTitle}>Documentation</span>
47+
<button
48+
type="button"
49+
className={styles.drawerClose}
50+
onClick={onClose}
51+
aria-label="Close menu"
52+
>
53+
×
54+
</button>
55+
</div>
56+
<DocsSidebar
57+
isDrawer
58+
onNavigate={onClose}
59+
activeSlug={activeSlug}
60+
/>
61+
</div>
62+
</div>
63+
);
64+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { describe, expect, it } from 'vitest';
4+
import { DocsHome } from './DocsHome';
5+
6+
function renderHome() {
7+
return render(
8+
<MemoryRouter>
9+
<DocsHome />
10+
</MemoryRouter>,
11+
);
12+
}
13+
14+
describe('DocsHome', () => {
15+
it('renders the headline and tagline', () => {
16+
renderHome();
17+
expect(
18+
screen.getByRole('heading', { level: 1, name: /2anki documentation/i }),
19+
).toBeInTheDocument();
20+
expect(
21+
screen.getByText(/simplest way to turn what you're studying/i),
22+
).toBeInTheDocument();
23+
});
24+
25+
it('shows the primary Connect Notion CTA', () => {
26+
renderHome();
27+
const cta = screen.getByRole('link', { name: /Connect Notion in 5 min/i });
28+
expect(cta).toHaveAttribute(
29+
'href',
30+
'/documentation/start-here/connect-notion',
31+
);
32+
});
33+
34+
it('lists the four start-here cards linking to the right slugs', () => {
35+
const { container } = renderHome();
36+
const hrefs = Array.from(
37+
container.querySelectorAll('a[href^="/documentation/start-here/"]'),
38+
).map((a) => a.getAttribute('href'));
39+
expect(hrefs).toEqual(
40+
expect.arrayContaining([
41+
'/documentation/start-here/what-is-2anki',
42+
'/documentation/start-here/connect-notion',
43+
'/documentation/start-here/upload-a-file',
44+
'/documentation/start-here/open-in-anki',
45+
]),
46+
);
47+
});
48+
});

0 commit comments

Comments
 (0)