Skip to content

Offer and search the tags the desk already uses - #207

Merged
ssavutu merged 2 commits into
mainfrom
feat/seo-tag-suggestions
Aug 7, 2026
Merged

Offer and search the tags the desk already uses#207
ssavutu merged 2 commits into
mainfrom
feat/seo-tag-suggestions

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 7, 2026

Copy link
Copy Markdown
Member

Sanjana asked for this in Slack on Aug 5: in WordPress the tag field offered the tags she adds to nearly every article, and she'd click them instead of retyping. The CMS's SEO Tags box is a bare text field, so that shortcut was gone.

Two things now sit under the box. With it empty, the tags the desk uses most. Once you start typing, a search over every tag the archive has — 19,893 of them — because the tags nobody remembers the spelling of are exactly the ones that aren't popular. Retyping a tag from memory is how near-duplicates get coined, so finding the existing one has to be easier than retyping it.

How the ranking is built

There is no tags table. articles.tags holds a JSON array per article, so both the popular list and the search come from aggregating that column.

The aggregation runs in Go rather than as a JSON_TABLE query. The column is not reliably JSON — FormatTags falls back to a comma-joined string when marshalling fails, and untagged articles store "" — and parseStringListField already absorbs both spellings. Reusing it means the suggestion counts cannot disagree with what the article editor itself reads back.

Case is folded, because the archive carries Drexel and drexel as separate strings, and offering both as separate suggestions is exactly the duplicate-looking list this is meant to replace. The displayed spelling is the commonest one, with a deterministic tie-break so the row does not rename itself when the cache rebuilds.

The cache holds the whole ranking, not the top slice — ~20k entries against ~9k articles, a couple of megabytes, and far cheaper than a per-keystroke scan of the article table. Only the response is capped. Archived articles are excluded; drafts are not.

Ordering

Matches are ranked by how they match before how popular they are: exact, then prefix, then a word starting with the query, then anything containing it. Somebody typing "lacrosse" means the tag "lacrosse", and ordering on uses alone buries it under a better-used "Women's Lacrosse".

Against the real archive, q=lacrosse returns: Lacrosse (70), lacrosse dragons (1), Women's Lacrosse (51), Men's Lacrosse (46), Drexel Women's Lacrosse (24), Drexel Men's Lacrosse (21).

In the editor

The box is searched on a 200ms debounce, so a typed word is one request rather than six. The popular tags stay on screen as the instant fallback and are filtered client-side on the keystroke, so the row narrows immediately instead of sitting empty until the search returns; stale responses for a prefix already typed past are dropped. A query that matches nothing says "No existing tag matches. Press Enter to create it." — coining a new tag is fine, but an editor should know that is what they're doing.

The trap worth knowing about

Clicking a suggestion blurs the tag input, and blur commits whatever is in it. Typing drex and clicking drexel added both tags. The button suppresses the blur via onMouseDown preventDefault; removing that line makes the covering test fail, which I checked.

Tests

  • 9 unit tests on the ranking and match rules
  • 7 handler integration tests against a real MariaDB, skipped without CMS_TEST_DSN
  • 7 component tests in editArticleView.test.tsx, covering the blur trap, the debounce collapsing to one request, and finding a tag that is in the archive but not in the popular list

Full server suite, frontend suite (19/19), and tsc -b are green. Search quality was also eyeballed against the live 9,371-article archive with a read-only probe.

Two things for the reviewer

Based on #206, not main. This builds on the featured-article branch because the editor page changes sit next to each other. Merge #206 first and this retargets cleanly.

The swagger regen swept in unrelated catch-up. server/docs was already stale — canonical_url, photo_alt, featured_image_alt and others were missing — so swag init added lines that aren't this endpoint. All generated, no deletions, but say the word and I'll drop the docs from the branch.

Not visually checked. The authed editor page needs the stubbed-entry-point + Playwright route to screenshot, which I did not set up. Behavior is covered by component tests, but nobody has looked at the row yet.

🤖 Generated with Claude Code

The SEO Tags box was a bare text field, so the boilerplate tags that go on
nearly every article -- triangle, drexel, drexel triangle -- were retyped by
hand each time. WordPress offered them as a click-to-add list and editors
relied on it.

There is no tags table: `articles`.`tags` holds a JSON array per article, so
"frequently used" can only come from aggregating the column. The ranking runs
in Go rather than as a JSON_TABLE query, because the column is not reliably
JSON -- FormatTags falls back to a comma-joined string, and untagged articles
store "" -- and parseStringListField already absorbs both spellings. Reusing it
means the suggestion counts cannot disagree with what the editor reads back.
Case is folded, since the imported archive carries Drexel and drexel as
separate strings and offering both is the duplicated list this replaces.

Clicking a suggestion blurs the tag input, and blur commits the draft, so
typing "drex" and clicking "drexel" added both tags. The button suppresses the
blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from feat/featured-article to main August 7, 2026 05:16
The suggestions only filtered the cached top 50, so a tag like "Men's
Lacrosse" -- 46 articles, nowhere near the top -- was invisible the moment
somebody started typing it. Retyping it from memory is how a near-duplicate of
an existing tag gets coined, which is the thing the suggestions exist to
prevent.

The cached ranking now holds every distinct tag rather than the top slice. That
is ~20k entries against ~9k articles, a couple of megabytes, and far cheaper
than a per-keystroke scan of the article table; only the response is capped.

Matches are ordered by how they match before how popular they are: exact, then
prefix, then a word starting with the query, then anything containing it.
Somebody typing "lacrosse" means the tag "lacrosse", and ranking on uses alone
buries it under a better-used "Women's Lacrosse".

GET /v1/tags/popular becomes GET /v1/tags?q=, since it is no longer only about
popularity. The box is searched on a 200ms debounce, so a typed word is one
request; the popular tags stay on screen as the instant fallback, and a query
that finds nothing says so rather than leaving the row empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu ssavutu changed the title Offer the tags the desk already uses Offer and search the tags the desk already uses Aug 7, 2026
@ssavutu
ssavutu merged commit f5af16f into main Aug 7, 2026
6 checks passed
@ssavutu
ssavutu deleted the feat/seo-tag-suggestions branch August 7, 2026 05:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant