fix: every cross-reference in the corpus was a dead link

`[[403|urethritis]]` became `<ArticleLink slug="403">`, which built the
href `/articles/s/403` — the slug route — and asked the preview endpoint
to resolve "403" as a slug. Neither exists, so the hover card never
appeared and the link 404'd. Every one of the 2,150 links is written by
id, because an id survives a rename and a slug does not, so this was the
whole library and not one article.

resolve_slug now takes an id as well as a current or historical slug,
and the link addresses the article directly when it is written by id.

Also: a view of one section no longer prints a heading repeating the tab
above it. "Short" over a heading reading "In short" says the same word
twice, and hid the only content behind a chevron. No collapse control
over a single section, and no contents list of one entry.

And a horizontal-overflow guard that only half worked: `overflow-x:
hidden` was on body but not html, so the browser could still propagate
the overflow to the viewport and scroll the whole page sideways — which
is how the navbar came to be clipped mid-word. The exam name now
truncates with an ellipsis instead of clipping.

Backend 242/242, frontend 316/316.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 18:25:31 +02:00
parent e176068f26
commit 14a75303a7
9 changed files with 70 additions and 23 deletions

View file

@ -81,14 +81,22 @@ def record_slug(db: Session, article: Article) -> None:
def resolve_slug(db: Session, slug: str) -> Article | None:
"""Find an article by its current slug, or by one it used to have."""
slug = (slug or "").strip().lower()
if not slug:
"""Find an article by numeric id, by its current slug, or by an old one.
Cross-references in prose are written `[[403|urethritis]]` by id, because
an id survives a rename and a slug does not. So whatever resolves a
reference has to accept both: looking only at slugs made every one of those
links dead, with no preview and a 404 behind it.
"""
reference = (slug or "").strip().lower()
if not reference:
return None
article = db.query(Article).filter(Article.slug == slug).first()
if reference.isdigit():
return db.get(Article, int(reference))
article = db.query(Article).filter(Article.slug == reference).first()
if article:
return article
historical = db.query(ArticleSlug).filter(ArticleSlug.slug == slug).first()
historical = db.query(ArticleSlug).filter(ArticleSlug.slug == reference).first()
return db.get(Article, historical.article_id) if historical else None

View file

@ -44,7 +44,10 @@ export default function ArticleLink({ slug, children, className = '' }) {
const timer = useRef(null)
const anchor = useRef(null)
const split = useSplitView()
const href = `/articles/s/${slug}`
// A cross-reference written by id addresses the article directly; one
// written by slug goes through the slug route, which also resolves the
// names an article used to have.
const href = /^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`
useEffect(() => () => clearTimeout(timer.current), [])

View file

@ -104,6 +104,10 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
: {})
const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
// One top-level section with nothing under it is the whole view, so it is
// shown as prose rather than as a contents page of one entry.
const soleSection = topSections.length === 1 && allSections.length === 1 ? topSections[0] : null
const renderSection = (sec, depth) => {
const isOpen = !!openIds[sec.id]
const kids = kidsOf(sec.id)
@ -141,6 +145,8 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
<span aria-hidden="true">{railOpen ? '' : ''}</span>
</button>
<h4>{article.title}</h4>
{/* A contents list of one entry is not a contents list. */}
{!soleSection && (
<ul className="atoc">
{topSections.map(sec => (
<li key={sec.id}>
@ -165,6 +171,7 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
</li>
))}
</ul>
)}
</aside>
<Content className="article-content">
{article.updated_at && (
@ -194,14 +201,22 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
))}
</div>
)}
{allSections.length > 0 && (
{allSections.length > 0 && !soleSection && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
{allOpen ? 'Collapse all' : 'Expand all'}
</button>
)}
</div>
<div className="asec-list">
{topSections.map(sec => renderSection(sec, 0))}
{/* A view with one section does not need a heading over it, or a
control to collapse the only thing there is. The tab already
names it "Short" then a heading reading "In short" says the
same word twice and hides the content behind a chevron. */}
{soleSection ? (
<div className="asec-sole" id={`section-${idPrefix}${soleSection.id}`}>
<Markdown>{soleSection.content}</Markdown>
</div>
) : topSections.map(sec => renderSection(sec, 0))}
</div>
{(article.references || []).length > 0 && (

View file

@ -109,3 +109,8 @@
background: #fef3c7; color: #92400e;
}
.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }
/* Never wider than what contains it: a flex or grid item defaults to
min-width:auto, which lets a scroll container size to its content instead of
its box and then it is the page that scrolls, not the strip. */
.cc-wrap, .cc-columns { max-width: 100%; min-width: 0; }

View file

@ -56,8 +56,11 @@ describe('rendered prose', () => {
it('turns a cross-reference into a preview link when asked', () => {
mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true })
// By id, so the link addresses the article directly. It used to build
// /articles/s/7 the slug route where "7" is not a slug, so every
// cross-reference in the corpus was a dead link with no preview.
expect(screen.getByRole('link', { name: 'Febrile seizures' }))
.toHaveAttribute('href', '/articles/s/7')
.toHaveAttribute('href', '/articles/7')
})
})

View file

@ -107,9 +107,15 @@ body {
color: var(--text);
line-height: 1.6;
transition: background 0.25s, color 0.25s;
overflow-x: hidden;
}
/* Both, not just body: with `overflow-x: hidden` on body alone the browser can
still propagate the overflow to the viewport, and one element a few pixels
too wide then scrolls the whole page sideways which is why the navbar
could be found clipped mid-word at its left edge. Anything genuinely wider
than the window scrolls inside its own box instead. */
html, body { overflow-x: hidden; max-width: 100%; }
/* ── Layout ─────────────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
@ -815,7 +821,11 @@ body {
.exam-switcher select {
background: var(--input-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 6px 10px; font-size: .8rem; font-weight: 600; max-width: 220px; cursor: pointer;
padding: 6px 10px; font-size: .8rem; font-weight: 600; cursor: pointer;
/* Truncate with an ellipsis rather than clipping: "Pediatrics Boards (2948)"
is wider than the old 220px cap, and a name cut mid-word reads as a
rendering fault rather than as a name too long to show. */
max-width: min(260px, 32vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.exam-switcher select:hover { border-color: var(--primary); color: var(--primary); }
.exam-switcher select option { color: var(--text); background: var(--card-bg); }

View file

@ -78,11 +78,11 @@ describe('reading a cross-reference beside the article', () => {
await openSplit('meningitis')
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
// Contents rail and collapsible heading both, the same as the page behind
// it. A single-section view opens on arrival rather than showing a heading
// over a blank space.
expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument()
// The pane is the same reader as the page behind it so a view of one
// section is shown as prose there too, with no heading repeating the view
// and no contents list of a single entry.
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
expect(within(pane).queryByRole('button', { name: 'Signs' })).not.toBeInTheDocument()
})
it('closing it gives the page back its single column', async () => {

View file

@ -222,3 +222,7 @@
.asec-controls { gap: 8px; }
.aview { padding: 6px 10px; font-size: 0.79rem; }
}
/* A view that is one section is shown as prose: no heading repeating the tab
above it, no chevron hiding the only thing there is to read. */
.asec-sole { padding-top: 4px; }

View file

@ -83,16 +83,15 @@ describe('topic reading', () => {
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
// A view of one section is not a contents page, so it opens: a lone
// heading over a blank space reads as a view with nothing in it.
expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
// A view of one section is the prose, not a contents page of one entry.
// The tab already names the view "Short" over a heading reading "In
// short" says the same word twice and hides the only content behind a
// chevron.
expect(screen.getByText('Section markdown')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: /Initial workup/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Expand all|Collapse all/ })).not.toBeInTheDocument()
expect(document.querySelector('.article-sections .atoc')).toBeNull()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// The contents rail still marks the section it jumps to.
const toc = document.querySelector('.article-sections')
await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' }))
expect(within(toc).getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
// Reading a topic offers a test; it never prints the stem, answer or explanation.
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()