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:
parent
e176068f26
commit
14a75303a7
9 changed files with 70 additions and 23 deletions
|
|
@ -81,14 +81,22 @@ def record_slug(db: Session, article: Article) -> None:
|
||||||
|
|
||||||
|
|
||||||
def resolve_slug(db: Session, slug: str) -> 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."""
|
"""Find an article by numeric id, by its current slug, or by an old one.
|
||||||
slug = (slug or "").strip().lower()
|
|
||||||
if not slug:
|
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
|
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:
|
if article:
|
||||||
return 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
|
return db.get(Article, historical.article_id) if historical else None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ export default function ArticleLink({ slug, children, className = '' }) {
|
||||||
const timer = useRef(null)
|
const timer = useRef(null)
|
||||||
const anchor = useRef(null)
|
const anchor = useRef(null)
|
||||||
const split = useSplitView()
|
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), [])
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
||||||
: {})
|
: {})
|
||||||
const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
|
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 renderSection = (sec, depth) => {
|
||||||
const isOpen = !!openIds[sec.id]
|
const isOpen = !!openIds[sec.id]
|
||||||
const kids = kidsOf(sec.id)
|
const kids = kidsOf(sec.id)
|
||||||
|
|
@ -141,6 +145,8 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
||||||
<span aria-hidden="true">{railOpen ? '‹' : '›'}</span>
|
<span aria-hidden="true">{railOpen ? '‹' : '›'}</span>
|
||||||
</button>
|
</button>
|
||||||
<h4>{article.title}</h4>
|
<h4>{article.title}</h4>
|
||||||
|
{/* A contents list of one entry is not a contents list. */}
|
||||||
|
{!soleSection && (
|
||||||
<ul className="atoc">
|
<ul className="atoc">
|
||||||
{topSections.map(sec => (
|
{topSections.map(sec => (
|
||||||
<li key={sec.id}>
|
<li key={sec.id}>
|
||||||
|
|
@ -165,6 +171,7 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
<Content className="article-content">
|
<Content className="article-content">
|
||||||
{article.updated_at && (
|
{article.updated_at && (
|
||||||
|
|
@ -194,14 +201,22 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{allSections.length > 0 && (
|
{allSections.length > 0 && !soleSection && (
|
||||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setAll(!allOpen)}>
|
||||||
{allOpen ? 'Collapse all' : 'Expand all'}
|
{allOpen ? 'Collapse all' : 'Expand all'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="asec-list">
|
<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>
|
</div>
|
||||||
|
|
||||||
{(article.references || []).length > 0 && (
|
{(article.references || []).length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -109,3 +109,8 @@
|
||||||
background: #fef3c7; color: #92400e;
|
background: #fef3c7; color: #92400e;
|
||||||
}
|
}
|
||||||
.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,11 @@ describe('rendered prose', () => {
|
||||||
|
|
||||||
it('turns a cross-reference into a preview link when asked', () => {
|
it('turns a cross-reference into a preview link when asked', () => {
|
||||||
mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true })
|
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' }))
|
expect(screen.getByRole('link', { name: 'Febrile seizures' }))
|
||||||
.toHaveAttribute('href', '/articles/s/7')
|
.toHaveAttribute('href', '/articles/7')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,15 @@ body {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
transition: background 0.25s, color 0.25s;
|
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 ─────────────────────────────────────────────────── */
|
/* ── Layout ─────────────────────────────────────────────────── */
|
||||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
|
.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
|
||||||
|
|
||||||
|
|
@ -815,7 +821,11 @@ body {
|
||||||
.exam-switcher select {
|
.exam-switcher select {
|
||||||
background: var(--input-bg); color: var(--text);
|
background: var(--input-bg); color: var(--text);
|
||||||
border: 1px solid var(--border); border-radius: 8px;
|
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:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
.exam-switcher select option { color: var(--text); background: var(--card-bg); }
|
.exam-switcher select option { color: var(--text); background: var(--card-bg); }
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,11 @@ describe('reading a cross-reference beside the article', () => {
|
||||||
await openSplit('meningitis')
|
await openSplit('meningitis')
|
||||||
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
|
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
|
||||||
|
|
||||||
// Contents rail and collapsible heading both, the same as the page behind
|
// The pane is the same reader as the page behind it — so a view of one
|
||||||
// it. A single-section view opens on arrival rather than showing a heading
|
// section is shown as prose there too, with no heading repeating the view
|
||||||
// over a blank space.
|
// and no contents list of a single entry.
|
||||||
expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument()
|
|
||||||
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
|
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 () => {
|
it('closing it gives the page back its single column', async () => {
|
||||||
|
|
|
||||||
|
|
@ -222,3 +222,7 @@
|
||||||
.asec-controls { gap: 8px; }
|
.asec-controls { gap: 8px; }
|
||||||
.aview { padding: 6px 10px; font-size: 0.79rem; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -83,16 +83,15 @@ describe('topic reading', () => {
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
|
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
|
||||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||||
// A view of one section is not a contents page, so it opens: a lone
|
// A view of one section is the prose, not a contents page of one entry.
|
||||||
// heading over a blank space reads as a view with nothing in it.
|
// The tab already names the view — "Short" over a heading reading "In
|
||||||
expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
|
// short" says the same word twice and hides the only content behind a
|
||||||
|
// chevron.
|
||||||
expect(screen.getByText('Section markdown')).toBeInTheDocument()
|
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()
|
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.
|
// 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(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue