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:
|
||||
"""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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue