feat: the id, the slug and the marker at the top of the editor

Both identifiers were looked for and neither was on the page: the id had to be
read out of the address bar and the slug out of a form field below the fold.
They are now two chips under the title, each copying itself.

The third chip is the one that earns its place — the cross-reference marker
itself, `[[264|Pediatric Respiratory Failure]]`, id and title already
assembled, because that is what somebody wants those two things *for*.

And the broken-link check finally shows. The backend has computed
`broken_links` on every save since markers existed and nothing had ever
rendered it, which made the check pointless: a dead cross-reference found a
week later belongs to nobody. Saving now says which markers point at nothing —
and saves anyway, because a link to an article that has not been written yet is
a note to write it.

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-12 20:06:56 +02:00
parent dadd2447b9
commit 2d80df8d73
3 changed files with 114 additions and 1 deletions

View file

@ -414,3 +414,48 @@
.article-danger p { margin: 0 0 10px; font-size: 0.86rem; line-height: 1.55; color: var(--text-muted); max-width: 62ch; }
.article-danger-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.article-danger-open { color: var(--wrong-fg); }
/* The identifiers, at the top of the editor. Both are looked for and neither
was anywhere on the page: the id had to be read out of the address bar and
the slug out of the form field below.
The third chip is the useful one the cross-reference marker itself, id and
title already assembled, because that is what somebody wants these two
numbers *for*. */
.article-ids { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.article-id {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px;
background: var(--card-bg); font: inherit; font-size: 0.78rem; color: var(--text-muted);
}
.article-id:hover { border-color: var(--primary); color: var(--text); }
.article-id span { font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; font-size: 0.66rem; }
.article-id code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.76rem; color: var(--text);
}
.article-id.is-marker code { color: var(--primary); }
.article-id.is-marker { max-width: 100%; overflow: hidden; }
.article-id.is-marker code { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.article-id.is-copied { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); }
.article-id-said { font-size: 0.76rem; font-weight: 600; color: var(--primary); }
@media (max-width: 560px) {
.article-id.is-marker { display: none; } /* The marker is a desktop errand. */
}
/* Cross-references that point at nothing, said on save the last moment the
person who wrote the link is still looking at it. The backend has computed
this since markers existed; nothing had ever shown it. */
.article-broken {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
padding: 11px 14px; border-radius: 9px;
background: color-mix(in srgb, var(--wrong-fg) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--wrong-fg) 24%, transparent);
font-size: 0.86rem;
}
.article-broken code {
display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 5px;
background: var(--card-bg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
}
.article-broken-hint { font-size: 0.8rem; color: var(--text-muted); }

View file

@ -231,6 +231,12 @@ export function ArticlePage() {
const [savedForm, setSavedForm] = useState(null)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
//: Which identifier was last copied, so the button can say so. Cleared on a
//: timer rather than left standing: "Copied" next to a button nobody has
//: pressed for a minute is a lie about the clipboard.
const [copied, setCopied] = useState('')
//: Cross-references in this article that point at nothing, as of the last save.
const [broken, setBroken] = useState([])
const [aiJob, setAiJob] = useState(null)
const [aiMessage, setAiMessage] = useState('')
const [showRefine, setShowRefine] = useState(false)
@ -301,6 +307,12 @@ export function ArticlePage() {
//: next time a field is added.
const dirty = !!form && !!savedForm && JSON.stringify(form) !== JSON.stringify(savedForm)
const copyBit = (which, value) => {
navigator.clipboard?.writeText(value)
setCopied(which)
setTimeout(() => setCopied(''), 2000)
}
const remove = async () => {
setSaving(true); setError('')
try {
@ -330,6 +342,11 @@ export function ArticlePage() {
const payload = { ...form }
const res = await api.patch(`/articles/${id}`, payload)
setArticle(res.data)
// The backend has checked every cross-reference in the body and says
// which point at nothing. It has said so since the markers existed and
// nothing has ever shown it a dead link found a week later belongs to
// nobody, which was the whole reason for checking on save.
setBroken(res.data?.broken_links || [])
if (publish !== null) {
await api.post(`/articles/${id}/publish`, { published: publish })
setArticle(prev => ({ ...prev, status: publish ? 'published' : 'draft' }))
@ -397,10 +414,23 @@ export function ArticlePage() {
// Moderator business, and rare: kept above the reading rather than folded
// into it, with the page's gutters back so an alert is not flush to the
// window edge.
const hasNotices = !!error || (showRefine && user?.is_moderator)
const hasNotices = !!error || broken.length > 0 || (showRefine && user?.is_moderator)
const notices = (
<>
{error && <div className="form-error" role="alert">{error}</div>}
{broken.length > 0 && (
<div className="article-broken" role="alert">
<strong>
{broken.length} cross-reference{broken.length === 1 ? '' : 's'} in this article
point{broken.length === 1 ? 's' : ''} at nothing:
</strong>
<span>{broken.map(marker => <code key={marker}>{marker}</code>)}</span>
<span className="article-broken-hint">
Saved anyway a link to an article that does not exist yet is a note to
write it. It will start working the moment that article does.
</span>
</div>
)}
{showRefine && user?.is_moderator && (
<div className="card" style={{ marginBottom: 12 }}>
<h4>AI refine</h4>
@ -426,6 +456,27 @@ export function ArticlePage() {
Back to the article
</button>
<h1>{article.title} <DraftBadge status={article.status} /></h1>
{/* The two identifiers, at the top where they are looked for, and
the marker that uses them. The id is what a cross-reference
should carry a slug can be renamed, an id cannot so the
third button hands over the whole thing, ready to paste into
another article. */}
<div className="article-ids">
<button type="button" className={`article-id${copied === 'id' ? ' is-copied' : ''}`}
title="Copy this article's id" onClick={() => copyBit('id', String(article.id))}>
<span>ID</span><code>{article.id}</code>
</button>
<button type="button" className={`article-id${copied === 'slug' ? ' is-copied' : ''}`}
title="Copy this article's slug" onClick={() => copyBit('slug', article.slug)}>
<span>Slug</span><code>{article.slug}</code>
</button>
<button type="button" className={`article-id is-marker${copied === 'marker' ? ' is-copied' : ''}`}
title="Copy a link to this article, ready to paste into another one"
onClick={() => copyBit('marker', `[[${article.id}|${article.title}]]`)}>
<code>[[{article.id}|{article.title}]]</code>
</button>
{copied && <span className="article-id-said" role="status">Copied</span>}
</div>
</div>
<div className="article-header-actions">
{/* A way out that is not Save. There was none: the only controls

View file

@ -256,6 +256,23 @@ describe('topic reading', () => {
// three. The contents are filtered by the same choice as the body, so the
// rail can never offer a heading the article is no longer showing which is
// the failure worth guarding, not the switch itself.
it('says which cross-references point at nothing, on save', async () => {
api.get.mockImplementation(url => (url === '/articles/1'
? Promise.resolve({ data: article })
: Promise.resolve({ data: [] })))
api.patch.mockResolvedValue({ data: { ...article, broken_links: ['[[999|…]]', '[[no-such-topic]]'] } })
render(<MemoryRouter initialEntries={['/articles/1?edit=1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
await userEvent.click(await screen.findByRole('button', { name: 'Save' }))
const notice = await screen.findByRole('alert')
expect(notice).toHaveTextContent('2 cross-references in this article point at nothing')
expect(within(notice).getByText('[[999|…]]')).toBeInTheDocument()
expect(within(notice).getByText('[[no-such-topic]]')).toBeInTheDocument()
// Said, not refused: a link to an article nobody has written yet is a note
// to write it.
expect(notice).toHaveTextContent('Saved anyway')
})
it('keeps the contents rail in step with the depth being read', async () => {
const layered = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'key', title: 'Key points', content: 'Key body', variant: 'short' },