feat: language picker for code blocks#1176
Conversation
🦋 Changeset detectedLatest commit: ef0fcb8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 811 lines across 12 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | ef0fcb8 | May 26 2026, 03:44 PM |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | ef0fcb8 | May 26 2026, 03:44 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | ef0fcb8 | May 26 2026, 03:44 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Adds an inline language picker to TipTap code blocks in both the admin PortableTextEditor and the runtime inline visual editor, persisting the chosen language on the code block node attribute and round-tripping through Portable Text.
Changes:
- Swap StarterKit’s default
codeBlockfor custom CodeBlock extensions that render a node view with a language chip + popover input. - Introduce curated language lists + normalization helpers (admin + duplicated runtime version) and add unit tests for them.
- Add required TipTap dependency (
@tiptap/extension-code-block) and a changeset entry.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Adds @tiptap/extension-code-block to the workspace catalog. |
| pnpm-lock.yaml | Locks the new TipTap extension version and updates lockfile resolution. |
| packages/core/package.json | Adds runtime dependency on @tiptap/extension-code-block. |
| packages/core/src/components/InlinePortableTextEditor.tsx | Replaces StarterKit codeBlock with InlineCodeBlockExtension. |
| packages/core/src/components/inline-code-block.tsx | New runtime code block node view with inline language picker + normalization. |
| packages/admin/package.json | Adds admin dependency on @tiptap/extension-code-block. |
| packages/admin/src/components/PortableTextEditor.tsx | Replaces StarterKit codeBlock with CodeBlockExtension. |
| packages/admin/src/components/editor/CodeBlockNode.tsx | New admin code block node view (Kumo/Lingui) with inline language picker. |
| packages/admin/src/components/editor/codeBlockLanguages.ts | New curated language list + findLanguage/normalizeLanguage/languageLabel helpers. |
| packages/admin/tests/editor/CodeBlockNode.test.ts | New tests validating the custom code block extension behavior. |
| packages/admin/tests/editor/codeBlockLanguages.test.ts | New tests for alias resolution and normalization helpers. |
| .changeset/code-block-language-picker.md | Declares minor version bumps for the admin + root package. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function normalizeLanguage(value: string | null | undefined): string | undefined { | ||
| if (!value) return undefined; | ||
| const trimmed = value.trim(); | ||
| if (!trimmed) return undefined; | ||
| const match = findLanguage(trimmed); | ||
| if (match) return match.id; | ||
| return trimmed.toLowerCase(); | ||
| } |
| const DATALIST_ID = "emdash-code-block-languages"; | ||
|
|
||
| function CodeBlockLanguageDatalist() { | ||
| return ( | ||
| <datalist id={DATALIST_ID}> | ||
| {CODE_BLOCK_LANGUAGES.map((lang) => ( | ||
| <option key={lang.id} value={lang.id} label={lang.label} /> | ||
| ))} | ||
| </datalist> | ||
| ); |
| function normalizeLanguage(value: string | null | undefined): string | undefined { | ||
| if (!value) return undefined; | ||
| const trimmed = value.trim(); | ||
| if (!trimmed) return undefined; | ||
| const match = findLanguage(trimmed); | ||
| if (match) return match.id; | ||
| return trimmed.toLowerCase(); | ||
| } |
| const DATALIST_ID = "emdash-inline-code-block-languages"; | ||
|
|
||
| function findLanguage(value: string | null | undefined): CodeBlockLanguage | null { | ||
| if (!value) return null; | ||
| const needle = value.trim().toLowerCase(); | ||
| if (!needle) return null; | ||
| for (const lang of CODE_BLOCK_LANGUAGES) { | ||
| if (lang.id === needle) return lang; | ||
| if (lang.aliases?.includes(needle)) return lang; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function normalizeLanguage(value: string | null | undefined): string | undefined { | ||
| if (!value) return undefined; | ||
| const trimmed = value.trim(); | ||
| if (!trimmed) return undefined; | ||
| const match = findLanguage(trimmed); | ||
| if (match) return match.id; | ||
| return trimmed.toLowerCase(); | ||
| } | ||
|
|
||
| function languageLabel(value: string | null | undefined): string { | ||
| if (!value) return "Plain text"; | ||
| const match = findLanguage(value); | ||
| if (match) return match.label; | ||
| return value; | ||
| } | ||
|
|
||
| function CodeBlockLanguageDatalist() { | ||
| return ( | ||
| <datalist id={DATALIST_ID}> | ||
| {CODE_BLOCK_LANGUAGES.map((lang) => ( | ||
| <option key={lang.id} value={lang.id} label={lang.label} /> | ||
| ))} | ||
| </datalist> | ||
| ); | ||
| } |
| <div | ||
| contentEditable={false} | ||
| style={{ | ||
| position: "absolute", | ||
| top: "0.5rem", | ||
| insetInlineEnd: "0.5rem", | ||
| userSelect: "none", | ||
| zIndex: 1, | ||
| opacity: chipVisible ? 1 : 0, | ||
| pointerEvents: chipVisible ? "auto" : "none", | ||
| transition: "opacity 0.15s", | ||
| }} |
Adds an inline language picker chip to code blocks in both the admin
rich text editor and the inline visual editor. Hover reveals the chip,
clicking opens a popover with a free-text input and a curated suggestions
datalist. Aliases (ts, js, c++, etc.) normalize to canonical ids on
commit. The existing triple-backtick markdown shortcut continues to
pre-populate the language. Storage and frontend rendering are unchanged:
the language attribute round-trips through Portable Text and emits as a
language-{id} class on the rendered pre/code.
- Migrate the admin code-block language picker from a native <datalist>
to Kumo's Autocomplete (landed in Kumo 2.0). Filters by label, id, or
alias; renders a proper popup list rather than relying on the browser's
native datalist UI.
- Sanitize unknown language inputs to a single safe CSS class token in
normalizeLanguage. Previously a user could type "Objective C" and the
frontend would render class="language-objective c" (two classes).
Now collapses runs of disallowed chars to a single hyphen and trims
leading/trailing hyphens. Added unit tests for whitespace, dots,
slashes, punctuation-only inputs, and other edge cases.
- Use React.useId() for the inline editor's <datalist> id so multiple
code blocks (or multiple inline editors) on the same page don't
produce duplicate DOM ids.
- When the language chip is hidden, set tabIndex={-1} and aria-hidden
so keyboard users don't land on an invisible focus target.
Addresses review feedback on #1176.
bd68fef to
ef0fcb8
Compare
Addressed review feedback + migrated to Kumo AutocompleteRebased on Review comments addressed
Bonus: Kumo AutocompleteWith Kumo 2 in (#1177), the admin editor now uses All 913 admin tests pass, 0 lint diagnostics, typecheck clean. |
What does this PR do?
Adds an inline language picker to code blocks in both the admin rich text editor and the inline visual editor.
tsstorestypescript,c++storescpp,mdstoresmarkdown, etc.```html+ space creates a code block already set to HTML)languageattribute round-trips through Portable Text and emits aslanguage-{id}on the rendered<pre>/<code>, ready for any frontend highlighterConfigurable syntax highlighting will land in a follow-up PR. This one just wires up the storage path end-to-end.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (908 admin tests, 3453 core tests)pnpm formathas been runAI-generated code disclosure
Screenshots
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
code-blocks-language-picker. Updated automatically when the playground redeploys.