diff --git a/.changeset/bright-code-blocks.md b/.changeset/bright-code-blocks.md new file mode 100644 index 0000000000..245abb9d77 --- /dev/null +++ b/.changeset/bright-code-blocks.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": patch +"emdash": patch +--- + +Fixes code blocks in the admin and inline visual editors so supported languages are syntax-highlighted on borderless light and dark surfaces. diff --git a/.changeset/gentle-code-block-controls.md b/.changeset/gentle-code-block-controls.md new file mode 100644 index 0000000000..b5d9e3d41d --- /dev/null +++ b/.changeset/gentle-code-block-controls.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": patch +"emdash": patch +--- + +Adds matching code-block controls to the admin and inline visual editors for selecting a language and copying code. diff --git a/docs/src/content/docs/guides/querying-content.mdx b/docs/src/content/docs/guides/querying-content.mdx index 03718063d4..2e8831b57b 100644 --- a/docs/src/content/docs/guides/querying-content.mdx +++ b/docs/src/content/docs/guides/querying-content.mdx @@ -257,6 +257,58 @@ In edit mode, `{...entry.edit.title}` produces a `data-emdash-ref` attribute tha injects a TipTap editor. For image fields, it opens a media library popover. +### Styling inline code blocks + +Code blocks in the inline editor follow the system appearance by default. If your site has its own theme switch, set these properties under its light and dark selectors: + +| Property | Controls | +| --- | --- | +| `--emdash-inline-code-background` | Code-block surface | +| `--emdash-inline-code-foreground` | Untokenized code | +| `--emdash-inline-code-muted` | Comments and quotes | +| `--emdash-inline-code-keyword` | Keywords, literals, selectors, and deletions | +| `--emdash-inline-code-string` | Strings, attributes, symbols, and additions | +| `--emdash-inline-code-number` | Numbers and metadata | +| `--emdash-inline-code-title` | Titles, names, types, and built-ins | +| `--emdash-inline-code-border` | Language-control borders | +| `--emdash-inline-code-control-background` | Language-control surface | +| `--emdash-inline-code-control-foreground` | Language-control text and icons | +| `--emdash-inline-code-focus` | Keyboard focus indicator | + +The following example matches inline code blocks to a site that applies `.dark` to the `` element: + +```css title="src/styles/global.css" +:root { + --emdash-inline-code-background: #f7f7f5; + --emdash-inline-code-foreground: #24292f; + --emdash-inline-code-muted: #57606a; + --emdash-inline-code-keyword: #b8172a; + --emdash-inline-code-string: #0a3069; + --emdash-inline-code-number: #0550ae; + --emdash-inline-code-title: #7545c7; + --emdash-inline-code-border: #7d8590; + --emdash-inline-code-control-background: #fff; + --emdash-inline-code-control-foreground: #24292f; + --emdash-inline-code-focus: #0550ae; +} + +:root.dark { + --emdash-inline-code-background: #202020; + --emdash-inline-code-foreground: #f0f3f6; + --emdash-inline-code-muted: #c9d1d9; + --emdash-inline-code-keyword: #ffc1bb; + --emdash-inline-code-string: #b9ddff; + --emdash-inline-code-number: #a8d5ff; + --emdash-inline-code-title: #e5ccff; + --emdash-inline-code-border: #6e7681; + --emdash-inline-code-control-background: #161b22; + --emdash-inline-code-control-foreground: #f0f3f6; + --emdash-inline-code-focus: #a8d5ff; +} +``` + +Override the complete foreground palette when changing the background so every token remains readable. These properties style only the hydrated inline editor; style rendered site code blocks through your site's normal CSS. + ## Sorting Results `getEmDashCollection` does not guarantee sort order. Sort results in your template: diff --git a/docs/technical-specs/issue-2361-code-block-highlighting.md b/docs/technical-specs/issue-2361-code-block-highlighting.md new file mode 100644 index 0000000000..54e48112b4 --- /dev/null +++ b/docs/technical-specs/issue-2361-code-block-highlighting.md @@ -0,0 +1,308 @@ +# Editor code-block syntax highlighting and theming + +Status: Proposed +Issue: [#2361](https://github.com/emdash-cms/emdash/issues/2361) +Base: `origin/main` at `353ff4fd2e052e1a60ec31b92bdcd6024401997a` + +## Summary + +Code blocks in the admin Portable Text editor and inline visual editor store a language but render the code as one unhighlighted text node. The admin's current code foreground and background invert incorrectly across appearances, and the inline language controls hard-code light colors. + +Replace the plain TipTap code-block extension in both editors with TipTap's Lowlight extension. Register the bounded Lowlight common grammar set plus Dockerfile, disable language auto-detection, and preserve the existing React node views, language picker, Portable Text shape, and public renderer. Apply a scoped syntax palette in the admin and expose equivalent CSS variables for sites to theme the inline visual editor. + +## Goals + +- Highlight supported code using the language stored on the code block. +- Keep code, the language picker, and its controls readable in the admin's light and dark appearances. +- Give the inline visual editor accessible system light/dark defaults and a documented site-level override contract. +- Update highlighting immediately when an editor changes the language or code. +- Preserve stored Portable Text and all existing language-picker behavior. + +## Non-goals + +- Do not add highlighting to `packages/core/src/components/Code.astro` or any other public, logged-out rendering path. +- Do not change the curated language list or add Zig or Lua entries. +- Do not add a Shiki integration or guarantee highlighting for every free-form language value. +- Do not add Tab indentation. Issue [#2594](https://github.com/emdash-cms/emdash/issues/2594) tracks that behavior. +- Do not change View Live, preview reliability, edit-mode persistence, or the welcome dialog. Issues [#2595](https://github.com/emdash-cms/emdash/issues/2595) and [#2596](https://github.com/emdash-cms/emdash/issues/2596) cover those defects. +- Do not refactor the duplicated admin and inline language pickers or introduce a shared editor package. +- Do not change editor commands, code-block serialization, or the Portable Text schema. + +## Approved product decisions + +- A registered language is highlighted. An absent, `plaintext`, or unsupported language remains plain text. +- The highlighter never guesses a language. +- The admin follows its `data-mode` light/dark appearance. +- The inline editor follows the browser's system appearance by default. A site with its own explicit theme switch sets the documented CSS variables in that theme's selector. +- Public code-block rendering and language-list expansion remain separate work. + +No unresolved product decisions remain. + +## Verified current behavior + +The verified source tree and browser reproduction establish the following behavior: + +- `packages/admin/src/components/editor/CodeBlockNode.tsx` extends `@tiptap/extension-code-block` and renders `
`. The selected language is stored on `node.attrs.language`, but no tokenizer creates token spans. +- `packages/core/src/components/inline-code-block.tsx` repeats the same plain extension and node-view structure for visual editing. +- Both editors disable StarterKit's code block before registering their package-local replacement. The existing extension name, commands, backtick input rule, editable content DOM, and language attribute therefore remain the compatibility boundary. +- The admin maps prose code text to `--text-color-kumo-subtle` and the code background to `--color-kumo-contrast`. These roles invert across appearances and produce low-contrast dark-on-dark or gray-on-light combinations. +- The inline node view hard-codes light control surfaces. Its surrounding inline editor has some system-dark rules, but none cover the code block or language picker. +- Admin and core conversion paths already round-trip only `code` and `language`. Decoration markup is not part of the ProseMirror document and must not enter Portable Text. +- `codeBlockLanguages.ts` contains 33 suggestions and accepts sanitized free-form input. +- Lowlight's common set supports most of the curated identifiers or aliases but omits Dockerfile. Highlight.js has no registered Astro, MDX, Svelte, Vue, or Zig grammar in that set. +- TipTap 3.20.0 provides `@tiptap/extension-code-block-lowlight` 3.20.0. Its decoration plugin recalculates token spans for code changes, language-attribute changes, node insertion/removal, and collaboration transactions that replace a complete code block. +- TipTap's plugin calls `highlightAuto` when it cannot resolve a language. EmDash must override that fallback because auto-detection conflicts with the approved plain-text behavior. +- Existing admin tests cover the schema name, commands, and language attribute but do not assert rendered token spans. Existing inline visual-editing end-to-end tests provide the appropriate real-browser surface for the core editor. + +## Technical design + +### Dependencies and grammar bound + +Add catalog entries matching the current TipTap line for: + +- `@tiptap/extension-code-block-lowlight` `3.20.0` +- `lowlight` `^3.3.0` +- `highlight.js` `11.11.2` + +Declare all three as direct runtime dependencies of `@emdash-cms/admin` and `emdash`. `highlight.js` is a peer of TipTap's Lowlight extension and a dependency of Lowlight; declaring it directly makes resolution explicit under pnpm's peer rules. + +Each editor module imports `common` and `createLowlight` from `lowlight`, creates one package-local instance from the exported `common` grammar map, registers `highlight.js/lib/languages/dockerfile`, and reuses that instance for every editor on the page. Do not use Lowlight's exported `all` grammar map, dynamically fetch grammars, or create one instance per component render. + +The common grammar map also registers languages that are not picker suggestions. A free-form value such as `lua` is highlighted because the configured instance registers it. "Unsupported" means absent from that instance, not absent from the picker. Astro, MDX, Svelte, Vue, Zig, and other unregistered values take the approved plain-text fallback. + +The package-local object passed to TipTap must expose the Lowlight methods that TipTap validates: + +- Delegate `listLanguages` and `registered` to the configured Lowlight instance. +- Implement `highlight` as a guarded wrapper: call the configured grammar only when the package-local Lowlight instance registers the language; otherwise highlight as `plaintext`. +- Implement `highlightAuto` by highlighting as `plaintext` instead of detecting a language. +- Configure TipTap's `defaultLanguage` as `plaintext` so a missing language also takes the plain path. + +This facade is required even though most curated languages are registered. TipTap also checks its own Highlight.js core before it calls the supplied Lowlight object; another bundle consumer could register a grammar there that is absent from EmDash's package-local instance. Guarding both `highlight` and `highlightAuto` keeps unsupported strings plain and prevents that cross-consumer state from causing `lowlight.highlight()` to throw. + +TipTap's upstream plugin assumes `node.attrs.language` is a string before it checks registration. At each editor's Portable Text-to-ProseMirror boundary, pass the language through only when it is a non-empty string; otherwise set the node attribute to `null`. This guard matches the declared `language?: string` contract and prevents malformed stored values such as numbers or objects from crashing decoration setup. It does not normalize, replace, or otherwise rewrite valid string values. + +### Admin editor + +Change `CodeBlockExtension` to extend and configure `CodeBlockLowlight` while keeping its extension name `codeBlock` and the existing `ReactNodeViewRenderer(CodeBlockNodeView)`. ProseMirror decorations then render scoped `hljs-*` spans inside the existing `NodeViewContent`; the language picker, popover portal, commands, keyboard behavior, and content DOM do not change. + +Add syntax rules under `.emdash-code-block` in `packages/admin/src/styles.css`. Define role-based variables with the light values from the measured palette below, then override those variables under `[data-mode="dark"] .emdash-code-block`. The admin's explicit theme toggle is driven by `data-mode`; `light-dark()` alone would follow `color-scheme` and could remain on the operating-system appearance. Use the role variables at every declaration site rather than applying raw palette values directly. Do not import a global Highlight.js stylesheet: global `.hljs-*` rules could restyle plugin UI or consumer content, and an unscoped theme would not follow Kumo's appearance state reliably. + +Map Highlight.js classes into these roles: + +- Base code uses `background` and `foreground` on a borderless surface. +- Comments and quotes use `muted`. +- Keywords, literals, selectors, sections, links, and deletions use `keyword`. +- Strings, attributes, symbols, bullets, and additions use `string`. +- Numbers and metadata use `number`. +- Titles, names, types, built-ins, and selector identifiers use `title`. +- Any unlisted token inherits `foreground`. + +Keep the language trigger and popover on Kumo surface, text, border, and focus tokens. Syntax colors must not be reused for interactive, success, warning, or destructive UI meaning. + +### Inline visual editor + +Change `InlineCodeBlockExtension` to use the same bounded Lowlight configuration and plain-text fallback. Keep the current node view, language datalist, free-form normalization, focus behavior, logical `insetInlineEnd`, and save flow. + +Move code-block and language-control color declarations out of hard-coded inline values and into the existing style block emitted by `InlinePortableTextEditor.tsx`. Scope every rule to `.emdash-inline-code-block` or `.emdash-code-block` inside `.emdash-inline-editor` so the editor cannot restyle the host site's static code blocks. + +The inline editor exposes the following additive CSS custom properties: + +| Property | Role | +| ----------------------------------------- | -------------------------------------------- | +| `--emdash-inline-code-background` | Code-block surface | +| `--emdash-inline-code-foreground` | Untokenized code and fallback text | +| `--emdash-inline-code-muted` | Comments and quotes | +| `--emdash-inline-code-keyword` | Keywords, literals, selectors, and deletions | +| `--emdash-inline-code-string` | Strings, attributes, symbols, and additions | +| `--emdash-inline-code-number` | Numbers and metadata | +| `--emdash-inline-code-title` | Titles, names, types, and built-ins | +| `--emdash-inline-code-border` | Language-control boundary | +| `--emdash-inline-code-control-background` | Language control surface | +| `--emdash-inline-code-control-foreground` | Language control text and icons | +| `--emdash-inline-code-focus` | Keyboard focus indicator | + +Use each property through `var(--property, fallback)` rather than defining it on the component. An inherited site value must therefore override the fallback. Preserve compatibility with the existing `--emdash-inline-bg` customization by using it as the secondary fallback for the control surface. + +Use this measured fallback palette: + +| Role | Light | Dark | +| ------------------ | --------- | --------- | +| Background | `#f7f7f5` | `#202020` | +| Foreground | `#24292f` | `#f0f3f6` | +| Muted | `#57606a` | `#c9d1d9` | +| Keyword | `#b8172a` | `#ffc1bb` | +| String | `#0a3069` | `#b9ddff` | +| Number | `#0550ae` | `#a8d5ff` | +| Title | `#7545c7` | `#e5ccff` | +| Border | `#7d8590` | `#6e7681` | +| Control background | `#ffffff` | `#161b22` | +| Control foreground | `#24292f` | `#f0f3f6` | +| Focus | `#0550ae` | `#a8d5ff` | + +Use the same background and syntax values for the admin's internal roles. The syntax and control foregrounds measure at least APCA `|Lc| 75` and WCAG 2 `4.5:1` against their assigned backgrounds. Language-control borders and focus indicators measure at least `3:1`. Recalculate the rendered pairs if implementation details introduce alpha, blending, or a different surface. + +Provide the light values as normal fallbacks and the dark values inside `@media (prefers-color-scheme: dark)`. A host site that switches appearance independently of the operating system overrides the variables under its own theme selector. Theme changes update CSS only; they must not rebuild the editor, recreate the node view, alter selection, or save content. + +### Documentation contract + +Add a short subsection to the Visual Editing section of `docs/src/content/docs/guides/querying-content.mdx`. State that inline code blocks follow the system appearance by default, list the override variables, and show one light/dark site-theme example using the site's existing selectors. The documentation must distinguish inline editing from public `Code.astro` rendering. + +Do not change the existing "Code blocks - With syntax highlighting" line in the admin content guide. The implementation makes that editor-specific statement accurate. + +## State and failure behavior + +The editor derives all highlighting from current ProseMirror state: + +| State | Result | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Supported stored language | Lowlight adds token decorations for that grammar. | +| `plaintext`, empty, or missing language | Code stays readable with base foreground and no token colors. | +| Unsupported string language | The stored value and picker label remain intact; code uses the plain-text fallback. | +| Invalid non-string language | The editor treats it as missing and saves it as absent on the next content update. | +| Language changed in the picker | The attribute transaction replaces the decorations without changing code or selection. | +| Code edited, pasted, undone, or redone locally, or replaced completely by a remote transaction | Decorations are derived again from the resulting document state. | +| Theme or site variables changed | Existing token spans receive new CSS values without a document transaction or save. | + +Lowlight runs locally and synchronously. It performs no network request, inserts no HTML string, and stores no highlighted markup. A refresh reconstructs the same decorations from `code` and `language`. + +## Compatibility, security, and cost + +- Portable Text remains `{ _type: "code", code, language? }`. No migration, API change, or database query is required. +- Existing unsupported string languages, free-form values, language aliases, and code content continue to save and reload unchanged. Non-string language values are outside the published shape and are omitted after an editor save. +- Lowlight returns a syntax tree that TipTap converts to ProseMirror decorations. Code remains text content, so author input is not interpreted as HTML. +- No authorization, CSRF, preview-token, or public-route behavior changes. +- The anonymous rendering branch of `PortableText.astro` and `Code.astro` stays unchanged. The highlighter executes only in the admin or hydrated inline editor. +- Highlighting work is proportional to the total code-block text TipTap recalculates for a qualifying transaction. Keep the grammar set bounded to `common` plus Dockerfile and verify a document containing a 20,000-character code block remains responsive while typing, changing language, and switching theme. +- Record minified and gzip sizes for the admin JavaScript and the inline-editor client chunk before and after implementation. Using Lowlight's `all` grammar map, adding runtime grammar requests, or adding a new asset to logged-out page requests is a blocking scope change. + +## Accessibility, localization, RTL, and responsive behavior + +- Every syntax foreground must meet APCA `|Lc| 75` and WCAG 2 `4.5:1` against its code background in both appearances. Code meaning cannot depend on color because the source characters remain visible without token styles. +- The language trigger, input, Apply, and Cancel controls retain their existing names, tab order, focus behavior, and keyboard operation. Their text meets `4.5:1`; borders and focus indicators meet `3:1`. +- The code block retains horizontal scrolling for long lines. The control remains at the logical inline end and must not cover editable code at desktop or narrow widths. +- Existing logical positioning remains RTL-safe. Verify the admin in Arabic and an inline editor under `dir="rtl"`; do not mirror code characters or directional icons that have no directional meaning. +- No user-facing strings are added to the admin, so no Lingui catalog source changes are required. Do not include generated `messages.po` files. + +## Tests and manual verification + +Follow the repository bug workflow for each commit: add a meaningful failing test, implement the behavior, and rerun the narrow suite. + +### Automated tests + +- Preserve the schema-name, `toggleCodeBlock`, and language-attribute assertions in `packages/admin/tests/editor/CodeBlockNode.test.ts`. +- Extend `packages/admin/tests/editor/PortableTextEditor.test.tsx` through its existing browser render helper. A JavaScript block must render grammar-specific `hljs-*` token spans; changing its language must update the token classes; `plaintext`, Astro, Zig, and an arbitrary free-form value must render no syntax token spans. Verify editor JSON contains only the original code and language rather than decoration markup. +- Add a converter-boundary regression using a non-string language. Both editors must load without throwing, render plain code, and omit the invalid value from their next serialized result. +- Add one published `post-with-code` entry in `e2e/global-setup.ts`. Give it separate JavaScript and Astro blocks so supported and unsupported behavior can be observed without changing or saving shared fixture content. +- Add an admin end-to-end scenario that opens the seeded post, verifies token spans, switches between light and dark appearances, and measures every rendered syntax/control foreground against its actual computed background. The node view, editor selection, and saved value must survive the switch. +- Add `e2e/tests/code-block-highlighting.spec.ts`. In edit mode, verify supported token spans, plain fallback for the Astro block, system-light and system-dark fallback colors, and live site-variable overrides without recreating the editor or issuing a content update request. +- Verify long lines scroll horizontally, controls remain reachable by keyboard at a narrow viewport, and logical-end positioning works under `dir="rtl"`. +- Assert the public page outside edit mode still emits the existing `language-{id}` classes and no `hljs-*` token markup. This guards the explicit public-rendering exclusion. + +Do not add tests that only assert a dependency literal, copied palette value, CSS class string, or mock return value. The tests must fail when tokenization, fallback behavior, theme response, contrast, storage integrity, or editor interaction regresses. + +### Manual scenarios + +- In the admin, type and edit JavaScript, HTML, CSS, and Dockerfile blocks. Change each language and switch light, dark, and system appearances. +- In inline visual editing, repeat a supported language and an unsupported value under system light/dark, then under a site-defined explicit theme override. +- Type continuously in a 20,000-character code block, undo and redo, change its language, and confirm the caret does not jump and the picker does not close unexpectedly. +- Save and reload from both editors. Confirm code and language are unchanged and highlighting returns. +- Check the language trigger, input, Apply, and Cancel controls with keyboard-only navigation in left-to-right and right-to-left layouts. + +## Expected files and line budget + +Expected production and contract files: + +- `pnpm-workspace.yaml` +- `pnpm-lock.yaml` (generated; excluded from the hand-written line budget) +- `packages/admin/package.json` +- `packages/admin/src/components/PortableTextEditor.tsx` +- `packages/admin/src/components/editor/CodeBlockNode.tsx` +- `packages/admin/src/styles.css` +- `packages/core/package.json` +- `packages/core/src/components/inline-code-block.tsx` +- `packages/core/src/components/InlinePortableTextEditor.tsx` +- `docs/src/content/docs/guides/querying-content.mdx` +- one changeset covering `@emdash-cms/admin` and `emdash` + +Expected test files: + +- `packages/admin/tests/editor/CodeBlockNode.test.ts` +- `packages/admin/tests/editor/PortableTextEditor.test.tsx` +- `packages/core/tests/unit/components/inline-portable-text-code-block.test.ts` +- `e2e/global-setup.ts` +- `e2e/tests/code-block-highlighting.spec.ts` + +Projected hand-written changes are 150-230 production lines, 240-360 test lines, and 35-60 documentation/changeset lines. More than 275 production lines or 400 test lines requires a scope audit. More than 350 production lines or 500 test lines, a new workspace package, a custom ProseMirror highlighting plugin, Shiki, public-renderer work, or language-list changes blocks implementation until the specification is revised and approved. + +## Implementation sequence + +Implement this specification in two reviewable commits. Use the sequence `plan -> meaningful failing tests -> implementation -> adversarial review -> patch -> re-review -> checks -> scope audit -> local commit` for each commit. + +### Commit 1: Highlight and theme admin code blocks + +Responsibility: complete the bug fix for the admin Portable Text editor. + +- Add the catalog and admin runtime dependencies. +- Add the bounded Lowlight instance and plain-text fallback to `CodeBlockExtension`. +- Guard the admin Portable Text-to-ProseMirror language boundary against non-string values. +- Add scoped admin syntax roles and light/dark values. +- Add failing-first Portable Text editor browser tests for supported highlighting, unsupported fallback, language changes, and storage integrity. +- Add a malformed-language boundary regression to the admin Portable Text editor suite. +- Add the admin light/dark contrast and interaction E2E scenario. + +Projected lines: 70-110 production and 110-170 tests. The commit excludes inline-editor source, public rendering, documentation, and language-list changes. + +### Commit 2: Match inline visual editing and publish the contract + +Responsibility: provide equivalent highlighting and theme behavior in the inline visual editor. + +- Add the core runtime dependencies and package-local bounded Lowlight instance. +- Guard the inline Portable Text-to-ProseMirror language boundary against non-string values. +- Move inline code-block/control colors to scoped CSS variables with system fallbacks. +- Add an inline converter unit test for valid, unsupported, and invalid language values. +- Add failing-first inline E2E coverage for tokenization, fallback, overrides, RTL, narrow layout, persistence, and the unchanged public renderer. +- Document the inline theme variables in the Visual Editing guide. +- Add one patch changeset for `@emdash-cms/admin` and `emdash`: "Fixes code blocks in the admin and inline visual editors so supported languages are syntax-highlighted and readable in light and dark appearances." +- Record editor bundle deltas and run the final checks. + +Projected lines: 80-120 production, 130-190 tests, and 35-60 documentation/changeset lines. The commit depends on the catalog entries from commit 1 and excludes public highlighting, grammar expansion, and the other split issues. + +## Verification gates + +Run after each implementation edit: + +- `pnpm lint:quick` +- the affected admin browser or E2E test +- `pnpm typecheck` for package changes +- `pnpm typecheck:demos` when the inline editor or E2E fixture changes + +Run before the pull request: + +- both focused code-block test suites +- the existing Portable Text editor and visual-editing suites +- `pnpm exec playwright test e2e/tests/code-block-highlighting.spec.ts` +- `EMDASH_E2E_TARGET=cloudflare pnpm exec playwright test e2e/tests/code-block-highlighting.spec.ts` +- `pnpm format` +- `pnpm lint:json | jq '.diagnostics | length'` +- `pnpm --dir docs build` +- `git diff --check` +- changeset validation and package builds for `@emdash-cms/admin` and `emdash` + +The clean-worktree preflight `pnpm lint:json` did not return a result because pnpm did not complete in the dependency-free spec worktree. Establish and record a clean baseline after dependencies are available; do not report the lint gate as passing until the command returns zero diagnostics. + +## Acceptance criteria + +- A supported language produces grammar-specific token spans in the admin and inline visual editors. +- Missing, `plaintext`, and unsupported languages remain plain without errors or auto-detection. +- Editing code or changing the language updates highlighting without moving the caret, closing the picker, changing stored content, or requiring a reload. +- Admin code, syntax tokens, language controls, control borders, and focus indicators meet the specified contrast in light and dark appearances and update on theme changes. +- Inline code blocks meet the same visual contract under system light/dark defaults and under documented site CSS-variable overrides. +- The language picker remains keyboard-accessible, localized in the admin, RTL-safe, and usable at narrow widths. +- Saving and reloading through either editor preserves the exact code and language and reconstructs highlighting. +- Public `Code.astro` output, the curated language list, Portable Text, database/API behavior, and anonymous route/query counts remain unchanged. +- Bundle measurements confirm that only editor assets grow and that the implementation uses Lowlight's `common` grammar map plus Dockerfile rather than all grammars. +- Required tests, type checks, lint, formatting, package builds, documentation build, and changeset validation pass. + +## Authority + +This document authorizes no implementation, commit, push, pull request, rebase, merge, issue update, or other Git/GitHub mutation. Implementation requires a separate approved `$feat-implement` request. diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 8a51662520..b67054a585 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -257,6 +257,31 @@ async function seedTestData( await apiPost(baseUrl, token, `/_emdash/api/content/posts/${imagePostId}/publish`, {}); postIds.push(imagePostId); + const codePost = await apiPost(baseUrl, token, "/_emdash/api/content/posts", { + data: { + title: "Post With Code", + excerpt: "A post containing supported and unsupported code blocks", + body: [ + { + _type: "code", + _key: "code-js", + code: 'const greeting = "hello";\nconsole.log(greeting);', + language: "javascript", + }, + { + _type: "code", + _key: "code-astro", + code: '---\nconst title = "Hello";\n---\n

{title}

', + language: "astro", + }, + ], + }, + slug: "post-with-code", + }); + const codePostId = codePost.item?.id ?? codePost.id; + await apiPost(baseUrl, token, `/_emdash/api/content/posts/${codePostId}/publish`, {}); + postIds.push(codePostId); + return { collections, contentIds: { posts: postIds, pages: pageIds }, diff --git a/e2e/tests/code-block-highlighting.spec.ts b/e2e/tests/code-block-highlighting.spec.ts new file mode 100644 index 0000000000..3a3d61dc4d --- /dev/null +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -0,0 +1,424 @@ +import { test, expect } from "../fixtures"; + +test.describe("Admin code block highlighting", () => { + test.beforeEach(async ({ admin }) => { + await admin.devBypassAuth(); + await admin.goToContent("posts"); + await admin.waitForLoading(); + await admin.page.getByRole("link", { name: "Post With Code", exact: true }).click(); + await admin.waitForLoading(); + }); + + test("highlights supported languages and leaves unsupported languages plain", async ({ + admin, + }) => { + const codeBlocks = admin.page.locator(".emdash-code-block"); + await expect(codeBlocks).toHaveCount(2); + await expect(codeBlocks.nth(0).locator('span[class*="hljs-"]')).not.toHaveCount(0); + await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); + }); + + test("uses borderless code surfaces in light and dark appearances", async ({ admin }) => { + const codeBlock = admin.page.locator(".emdash-code-block").first(); + + await admin.page.evaluate(() => document.documentElement.setAttribute("data-mode", "light")); + await expect(codeBlock).toHaveCSS("background-color", "rgb(247, 247, 245)"); + await expect(codeBlock).toHaveCSS("border-top-width", "0px"); + + await admin.page.evaluate(() => document.documentElement.setAttribute("data-mode", "dark")); + await expect(codeBlock).toHaveCSS("background-color", "rgb(32, 32, 32)"); + await expect(codeBlock).toHaveCSS("border-top-width", "0px"); + }); + + test("shows aligned two-action controls and applies their actions", async ({ admin }) => { + const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + const languageButton = controls.getByRole("button", { name: /^Set language/ }); + const copyButton = controls.getByRole("button", { name: "Copy code" }); + let updateRequests = 0; + admin.page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { + updateRequests += 1; + } + }); + await admin.page.waitForTimeout(2200); + updateRequests = 0; + + await admin.page.mouse.move(0, 0); + await expect(controls).toHaveCSS("opacity", "0"); + await languageButton.focus(); + await expect(controls).toHaveCSS("opacity", "1"); + await codeBlockNode.hover(); + await expect(controls).toHaveCSS("opacity", "1"); + await expect(controls.getByRole("button")).toHaveCount(2); + await expect(languageButton).toHaveCSS("font-size", "14px"); + await expect(copyButton).toHaveCSS("font-size", "14px"); + await expect(copyButton).toHaveAttribute("data-kumo-component", "Toolbar.Button"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + const languageBox = await languageButton.boundingBox(); + const codeText = await codeBlockNode.locator("code").evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const rects = [...range.getClientRects()]; + return { top: rects[0]?.top, bottom: rects.at(-1)?.bottom }; + }); + expect(nodeBox).not.toBeNull(); + expect(controlsBox).not.toBeNull(); + expect(languageBox?.height).toBe(36); + expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 4, 0); + expect(controlsBox?.x).toBeCloseTo( + (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 4, + 0, + ); + expect(codeText.top ?? 0).toBeLessThan((controlsBox?.y ?? 0) + (controlsBox?.height ?? 0)); + const topGap = (codeText.top ?? 0) - (nodeBox?.y ?? 0); + const bottomGap = (nodeBox?.y ?? 0) + (nodeBox?.height ?? 0) - (codeText.bottom ?? 0); + expect(Math.abs(topGap - bottomGap)).toBeLessThanOrEqual(4); + + const currentLanguageLabel = await languageButton.getAttribute("aria-label"); + const nextLanguage = currentLanguageLabel?.includes("Python") ? "JavaScript" : "Python"; + await languageButton.click(); + await admin.page.mouse.move(0, 0); + await expect(controls).toHaveCSS("opacity", "1"); + const input = admin.page.getByPlaceholder("Search for a language…"); + const popup = admin.page.locator(".kumo-popover-popup"); + await expect(input).toBeVisible(); + await expect(input).toHaveCSS("font-size", "14px"); + await expect(admin.page.getByRole("option", { name: "Plain text" })).toHaveCSS( + "font-size", + "14px", + ); + await popup.evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)); + }); + const placeholderColor = await input.evaluate( + (element) => getComputedStyle(element, "::placeholder").color, + ); + const inputColor = await input.evaluate((element) => getComputedStyle(element).color); + expect(placeholderColor).not.toBe(inputColor); + + const popupBox = await popup.boundingBox(); + const openControlsBox = await controls.boundingBox(); + expect(popupBox?.x).toBeCloseTo(openControlsBox?.x ?? 0, 0); + const popupBelowGap = + (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); + const popupAboveGap = + (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); + expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); + + await admin.page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await copyButton.hover(); + await expect( + admin.page.locator(".kumo-tooltip-popup").filter({ hasText: "Copy code" }), + ).toBeVisible(); + await expect(admin.page.locator(".kumo-tooltip-popup")).toHaveCount(1); + await admin.page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); + await admin.page.waitForTimeout(2200); + expect(updateRequests).toBe(0); + + await languageButton.click(); + const autosaveResponse = admin.page.waitForResponse( + (response) => + response.request().method() === "PUT" && response.url().includes("/_emdash/api/content/"), + { timeout: 5000 }, + ); + await admin.page.getByRole("option", { name: nextLanguage }).click(); + await expect( + controls.getByRole("button", { name: `Set language (current: ${nextLanguage})` }), + ).toBeVisible(); + await autosaveResponse; + expect(updateRequests).toBe(1); + }); + + test("keeps controls and language search inside a narrow RTL editor", async ({ admin }) => { + await admin.page.setViewportSize({ width: 320, height: 800 }); + await admin.page.evaluate(() => { + document.querySelector(".emdash-code-block-node")?.setAttribute("dir", "rtl"); + }); + const codeBlockNode = admin.page.locator(".emdash-code-block-node").first(); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + await codeBlockNode.hover(); + await expect(controls).toBeVisible(); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 4, 0); + + await controls.getByRole("button", { name: /^Set language/ }).click(); + const popupBox = await admin.page.locator(".kumo-popover-popup").boundingBox(); + expect(popupBox).not.toBeNull(); + expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); + }); +}); + +test("keeps public code block rendering unchanged", async ({ page }) => { + await page.goto("/posts/post-with-code"); + + await expect(page.locator(".emdash-code pre.language-javascript")).toBeVisible(); + await expect(page.locator(".emdash-code pre.language-astro")).toBeVisible(); + await expect(page.locator('span[class*="hljs-"]')).toHaveCount(0); +}); + +test.describe("Inline code block highlighting", () => { + test.beforeEach(async ({ admin, page }) => { + await admin.devBypassAuth(); + await page.context().addCookies([ + { + name: "emdash-edit-mode", + value: "true", + domain: "localhost", + path: "/", + }, + ]); + await page.goto("/posts/post-with-code"); + await expect(page.locator(".emdash-inline-editor")).toBeVisible({ timeout: 15000 }); + }); + + test("highlights supported languages and leaves unsupported languages plain", async ({ + page, + }) => { + const codeBlocks = page.locator(".emdash-inline-code-block .emdash-code-block"); + await expect(codeBlocks).toHaveCount(2); + await expect(codeBlocks.nth(0).locator('span[class*="hljs-"]')).not.toHaveCount(0); + await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); + }); + + test("matches the admin controls without saving during control interactions", async ({ + page, + }) => { + const codeBlockNode = page.locator(".emdash-inline-code-block").first(); + const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + const languageButton = controls.getByRole("button", { name: /^Set language/ }); + const copyButton = controls.getByRole("button", { name: "Copy code" }); + let updateRequests = 0; + page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { + updateRequests += 1; + } + }); + await page.waitForTimeout(2200); + updateRequests = 0; + await page.emulateMedia({ colorScheme: "dark" }); + const outsideControlStyle = await page.evaluate(() => { + const outside = document.createElement("div"); + outside.className = "emdash-inline-code-block-controls-wrap"; + document.body.append(outside); + const style = getComputedStyle(outside); + const result = { + opacity: style.opacity, + pointerEvents: style.pointerEvents, + position: style.position, + }; + outside.remove(); + return result; + }); + expect(outsideControlStyle).toEqual({ + opacity: "1", + pointerEvents: "auto", + position: "static", + }); + + await page.mouse.move(0, 0); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }); + await expect(controlsWrap).toHaveCSS("opacity", "0"); + await languageButton.focus(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await codeBlockNode.hover(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await expect(controls.getByRole("button")).toHaveCount(2); + await expect(languageButton).toHaveCSS("font-size", "14px"); + await expect(copyButton).toHaveCSS("font-size", "14px"); + await expect(controls).toHaveCSS("background-color", "rgb(24, 24, 24)"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + const languageBox = await languageButton.boundingBox(); + const codeText = await codeBlockNode.locator("code").evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const rects = [...range.getClientRects()]; + return { top: rects[0]?.top, bottom: rects.at(-1)?.bottom }; + }); + expect(nodeBox).not.toBeNull(); + expect(controlsBox).not.toBeNull(); + expect(languageBox?.height).toBe(36); + expect(controlsBox?.y).toBeCloseTo((nodeBox?.y ?? 0) + 4, 0); + expect(controlsBox?.x).toBeCloseTo( + (nodeBox?.x ?? 0) + (nodeBox?.width ?? 0) - (controlsBox?.width ?? 0) - 4, + 0, + ); + expect(codeText.top ?? 0).toBeLessThan((controlsBox?.y ?? 0) + (controlsBox?.height ?? 0)); + const topGap = (codeText.top ?? 0) - (nodeBox?.y ?? 0); + const bottomGap = (nodeBox?.y ?? 0) + (nodeBox?.height ?? 0) - (codeText.bottom ?? 0); + expect(Math.abs(topGap - bottomGap)).toBeLessThanOrEqual(4); + + await languageButton.click(); + await page.mouse.move(0, 0); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + const input = page.getByPlaceholder("Search for a language…"); + const popup = page.locator(".emdash-inline-code-block-popover"); + await expect(input).toBeVisible(); + await expect(input).toBeFocused(); + await expect(input).toHaveCSS("font-size", "14px"); + await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); + const placeholderColor = await input.evaluate( + (element) => getComputedStyle(element, "::placeholder").color, + ); + const inputColor = await input.evaluate((element) => getComputedStyle(element).color); + expect(placeholderColor).not.toBe(inputColor); + + const popupBox = await popup.boundingBox(); + const openControlsBox = await controls.boundingBox(); + const viewportWidth = await page.evaluate(() => window.innerWidth); + const expectedPopupX = Math.min( + Math.max(openControlsBox?.x ?? 0, 16), + viewportWidth - 16 - (popupBox?.width ?? 0), + ); + expect(popupBox?.x).toBeCloseTo(expectedPopupX, 0); + const popupBelowGap = + (popupBox?.y ?? 0) - ((openControlsBox?.y ?? 0) + (openControlsBox?.height ?? 0)); + const popupAboveGap = + (openControlsBox?.y ?? 0) - ((popupBox?.y ?? 0) + (popupBox?.height ?? 0)); + expect(Math.max(popupBelowGap, popupAboveGap)).toBeCloseTo(8, 0); + + await input.fill("yaml"); + await expect(page.getByRole("option", { name: "YAML" })).toBeVisible(); + await expect + .poll(async () => { + const filteredPopupBox = await popup.boundingBox(); + const filteredControlsBox = await controls.boundingBox(); + const filteredBelowGap = + (filteredPopupBox?.y ?? 0) - + ((filteredControlsBox?.y ?? 0) + (filteredControlsBox?.height ?? 0)); + const filteredAboveGap = + (filteredControlsBox?.y ?? 0) - + ((filteredPopupBox?.y ?? 0) + (filteredPopupBox?.height ?? 0)); + return Math.max(filteredBelowGap, filteredAboveGap); + }) + .toBeCloseTo(8, 0); + await input.fill(""); + + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await expect(languageButton).toBeFocused(); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(controls.getByRole("button", { name: "Copied" })).toBeVisible(); + + await languageButton.click(); + await page.getByRole("option", { name: "Python" }).click(); + await expect( + controls.getByRole("button", { name: "Set language (current: Python)" }), + ).toBeVisible(); + await page.waitForTimeout(500); + expect(updateRequests).toBe(0); + }); + + test("keeps inline controls and search inside a narrow RTL viewport", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await page.evaluate(() => { + document.querySelector(".emdash-inline-code-block")?.setAttribute("dir", "rtl"); + }); + const codeBlockNode = page.locator(".emdash-inline-code-block").first(); + const controlsWrap = codeBlockNode.locator(".emdash-inline-code-block-controls-wrap"); + const controls = codeBlockNode.getByRole("toolbar", { name: "Code block actions" }); + await codeBlockNode.hover(); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + + const nodeBox = await codeBlockNode.boundingBox(); + const controlsBox = await controls.boundingBox(); + expect(controlsBox?.x).toBeCloseTo((nodeBox?.x ?? 0) + 4, 0); + + await controls.getByRole("button", { name: /^Set language/ }).click(); + const popup = page.locator(".emdash-inline-code-block-popover"); + const popupBox = await popup.boundingBox(); + expect(popupBox).not.toBeNull(); + expect(popupBox?.x ?? -1).toBeGreaterThanOrEqual(0); + expect((popupBox?.x ?? 0) + (popupBox?.width ?? 0)).toBeLessThanOrEqual(320); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual(288); + await expect(page.getByPlaceholder("Search for a language…")).toHaveCSS("font-size", "16px"); + await expect(page.getByRole("option", { name: "Plain text" })).toHaveCSS("font-size", "14px"); + }); + + test("updates system and site theme colors without remounting or saving", async ({ page }) => { + const editor = page.locator(".emdash-inline-editor"); + const editorHandle = await editor.elementHandle(); + const codeBlock = page.locator(".emdash-inline-code-block .emdash-code-block").first(); + let updateRequests = 0; + page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/_emdash/api/content/")) { + updateRequests += 1; + } + }); + + await page.emulateMedia({ colorScheme: "light" }); + const lightBackground = await codeBlock.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await expect(codeBlock).toHaveCSS("border-top-width", "0px"); + await page.emulateMedia({ colorScheme: "dark" }); + const darkBackground = await codeBlock.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(lightBackground).toBe("rgb(247, 247, 245)"); + expect(darkBackground).toBe("rgb(32, 32, 32)"); + await expect(codeBlock).toHaveCSS("border-top-width", "0px"); + + await page.evaluate(() => { + document.documentElement.style.setProperty( + "--emdash-inline-code-background", + "rgb(25, 35, 45)", + ); + document.documentElement.style.setProperty( + "--emdash-inline-code-foreground", + "rgb(245, 245, 245)", + ); + }); + + await expect(codeBlock).toHaveCSS("background-color", "rgb(25, 35, 45)"); + await expect(codeBlock).toHaveCSS("color", "rgb(245, 245, 245)"); + expect(await editorHandle?.evaluate((element) => element.isConnected)).toBe(true); + expect(updateRequests).toBe(0); + }); +}); + +test("keeps inline code controls visible on touch devices", async ({ browser, baseURL }) => { + if (!baseURL) throw new Error("Playwright baseURL is required"); + const context = await browser.newContext({ + baseURL, + hasTouch: true, + viewport: { width: 393, height: 852 }, + }); + try { + const page = await context.newPage(); + await page.goto("/_emdash/api/auth/dev-bypass?redirect=/"); + await context.addCookies([ + { + name: "emdash-edit-mode", + value: "true", + domain: "localhost", + path: "/", + }, + ]); + await page.goto("/posts/post-with-code"); + await expect(page.locator(".emdash-inline-editor")).toBeVisible({ timeout: 15000 }); + const controlsWrap = page + .locator(".emdash-inline-code-block") + .first() + .locator(".emdash-inline-code-block-controls-wrap"); + await expect(controlsWrap).toHaveCSS("opacity", "1"); + await expect(controlsWrap).toHaveCSS("pointer-events", "auto"); + } finally { + await context.close(); + } +}); diff --git a/packages/admin/package.json b/packages/admin/package.json index 7a3f82ca22..79ca5bdbc4 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -55,6 +55,7 @@ "@tiptap/extension-character-count": "catalog:", "@tiptap/extension-code": "catalog:", "@tiptap/extension-code-block": "catalog:", + "@tiptap/extension-code-block-lowlight": "catalog:", "@tiptap/extension-collaboration": "catalog:", "@tiptap/extension-drag-handle": "catalog:", "@tiptap/extension-drag-handle-react": "catalog:", @@ -80,6 +81,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.2", + "highlight.js": "catalog:", + "lowlight": "catalog:", "marked": "^17.0.3", "react-hotkeys-hook": "^5.2.4", "tailwind-merge": "^3.3.0", diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index c1f08950b5..56ad8e68bb 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -1014,9 +1014,13 @@ function convertPTBlock(block: PortableTextBlock): unknown { case "code": { if (!isCodeBlock(block)) return null; const codeBlock = block; + const language = + typeof codeBlock.language === "string" && codeBlock.language.length > 0 + ? codeBlock.language + : null; return { type: "codeBlock", - attrs: { language: codeBlock.language || null }, + attrs: { language }, content: codeBlock.code ? [{ type: "text", text: codeBlock.code }] : undefined, }; } diff --git a/packages/admin/src/components/editor/CodeBlockNode.tsx b/packages/admin/src/components/editor/CodeBlockNode.tsx index af7928deac..cc54126da0 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -1,35 +1,29 @@ /** * Code block node with language picker. * - * Wraps the base `@tiptap/extension-code-block` with a React node view that - * overlays a small language chip in the top-right corner. Clicking the chip - * opens a popover with a Kumo Autocomplete: a free-form text input plus a - * filtered list of curated language suggestions. The value is persisted on - * the node's `language` attribute and round-trips through Portable Text as - * `block.language`. + * Wraps the Lowlight code block with a React node view that + * overlays a Kumo action toolbar at the logical end of the block. The toolbar + * opens a searchable language popover and copies the raw code. The selected + * language is persisted on the node's `language` attribute and round-trips + * through Portable Text as `block.language`. * * The picker accepts arbitrary strings (not restricted to the curated list) * so that less common languages can still be used. Free-form input is * sanitized to a single safe CSS class token via `normalizeLanguage` so the * frontend's `language-{id}` class stays well-formed. * - * The popover content is rendered through Kumo's `Popover`, which portals it - * out of the editor's contentEditable DOM. That portal is load-bearing, not - * cosmetic: a code block is a non-atom ProseMirror node with live editable - * content, so if the picker's text input lived inside the node view, typing - * would move the DOM selection into it. ProseMirror reads that selection, - * dispatches a selection-correcting transaction, and the resulting node-view - * redraw recreates this React component mid-edit, tearing the picker down -- - * the "language picker loses focus and closes when you type" bug (issue - * #1200). Keeping the input outside the editor DOM avoids it entirely. + * Kumo's `Popover` portals the search input out of the contentEditable DOM so + * ProseMirror does not interpret input typing as an editor selection change. */ -import { Autocomplete, Button, Popover } from "@cloudflare/kumo"; +import { CommandPalette, Popover, Toolbar, Tooltip, TooltipProvider } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; -import { Check, X } from "@phosphor-icons/react"; -import CodeBlock from "@tiptap/extension-code-block"; +import { CaretDown, Check, Copy } from "@phosphor-icons/react"; +import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import type { NodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; +import dockerfile from "highlight.js/lib/languages/dockerfile"; +import { common, createLowlight } from "lowlight"; import * as React from "react"; import { @@ -38,9 +32,69 @@ import { normalizeLanguage, } from "./codeBlockLanguages"; -function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) { +const lowlight = createLowlight(common); +lowlight.register({ dockerfile }); + +const editorLowlight = { + highlight(language: string, value: string) { + return lowlight.highlight(lowlight.registered(language) ? language : "plaintext", value); + }, + highlightAuto(value: string) { + return lowlight.highlight("plaintext", value); + }, + listLanguages() { + return lowlight.listLanguages(); + }, + registered(language: string) { + return lowlight.registered(language); + }, +}; + +interface LanguageItem { + id: string; + label: string; + aliases?: string[]; +} + +async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + } + + const activeElement = document.activeElement; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.readOnly = true; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + const selection = document.getSelection(); + const previousRange = selection?.rangeCount ? selection.getRangeAt(0) : null; + textarea.select(); + try { + if (!document.execCommand("copy")) throw new Error("Clipboard copy failed"); + } finally { + textarea.remove(); + if (previousRange) { + selection?.removeAllRanges(); + selection?.addRange(previousRange); + } + if (activeElement instanceof HTMLElement && activeElement.isConnected) { + activeElement.focus(); + } + } +} + +function CodeBlockNodeView({ node, updateAttributes }: NodeViewProps) { const { t } = useLingui(); const [isEditing, setIsEditing] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [keyboardHighlightedLanguage, setKeyboardHighlightedLanguage] = + React.useState(null); + const copyResetTimer = React.useRef | null>(null); const storedLanguage = typeof node.attrs.language === "string" ? node.attrs.language : ""; const labelText = React.useCallback( @@ -52,27 +106,33 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) ); const languageItems = React.useMemo( - () => CODE_BLOCK_LANGUAGES.map((language) => t(language.label)), + () => + CODE_BLOCK_LANGUAGES.map((language) => ({ + id: language.id, + label: t(language.label), + aliases: language.aliases, + })), [t], ); const findLanguageByDisplayLabel = React.useCallback( - (label: string) => CODE_BLOCK_LANGUAGES.find((language) => t(language.label) === label), - [t], + (label: string) => languageItems.find((language) => language.label === label), + [languageItems], ); - const filterLanguages = React.useCallback( - (item: string, query: string) => { - if (!query) return true; - const searchText = query.toLowerCase(); - const lang = findLanguageByDisplayLabel(item); - if (!lang) return false; + const filterLanguages = React.useCallback((item: LanguageItem, query: string) => { + if (!query) return true; + const searchText = query.toLowerCase(); + if (item.label.toLowerCase().includes(searchText)) return true; + if (item.id.toLowerCase().includes(searchText)) return true; + return item.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; + }, []); - if (t(lang.label).toLowerCase().includes(searchText)) return true; - if (lang.id.toLowerCase().includes(searchText)) return true; - return lang.aliases?.some((alias) => alias.toLowerCase().includes(searchText)) ?? false; + React.useEffect( + () => () => { + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); }, - [findLanguageByDisplayLabel, t], + [], ); const [draft, setDraft] = React.useState(() => labelText(storedLanguage)); @@ -87,12 +147,14 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) }, [storedLanguage, isEditing, labelText]); const openPicker = React.useCallback(() => { - setDraft(storedLanguage ? labelText(storedLanguage) : ""); + setDraft(""); + setKeyboardHighlightedLanguage(null); setIsEditing(true); - }, [storedLanguage, labelText]); + }, []); const closePicker = React.useCallback(() => { setIsEditing(false); + setKeyboardHighlightedLanguage(null); setDraft(labelText(storedLanguage)); }, [storedLanguage, labelText]); @@ -103,99 +165,148 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) const next = selectedLanguage?.id ?? normalizeLanguage(raw); updateAttributes({ language: next ?? null }); setIsEditing(false); + setKeyboardHighlightedLanguage(null); }, [draft, findLanguageByDisplayLabel, updateAttributes], ); - // Enter commits the current draft. Escape is handled by the Popover itself - // (it calls onOpenChange(false) -> closePicker). - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + closePicker(); + return; + } + if (e.key === "Enter" && !keyboardHighlightedLanguage) { e.preventDefault(); commit(); } }; + const copyCode = React.useCallback(async () => { + try { + await copyTextToClipboard(node.textContent); + setCopied(true); + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + copyResetTimer.current = setTimeout(setCopied, 1500, false); + } catch { + setCopied(false); + } + }, [node.textContent]); + const label = labelText(storedLanguage); - // The chip is always rendered (so it can be discovered via hover) but its - // opacity is controlled by CSS: invisible by default, visible on hover, - // when this block is selected, when the picker is open, or when the - // block already has a language set. When hidden, also remove it from the - // tab order so it doesn't trap keyboard focus. - const chipPersistent = isEditing || Boolean(storedLanguage) || selected; + const currentLanguageId = normalizeLanguage(storedLanguage); + const controlsPersistent = isEditing || copied; return ( - +
 				 as="code" />
 			
-
+
(open ? openPicker() : closePicker())} > - e.preventDefault()} - className="rounded-md border bg-kumo-overlay/90 px-2 py-1 text-xs text-kumo-subtle opacity-0 transition-opacity hover:text-kumo-strong focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-kumo-brand group-hover:opacity-100 data-[persistent=true]:opacity-100" - data-persistent={chipPersistent ? "true" : "false"} - title={t`Set language`} - aria-label={t`Set language (current: ${label})`} - aria-hidden={chipPersistent ? undefined : true} - > - {storedLanguage ? label : t`Set language`} - - } - /> - -
- setDraft(next)} - filter={filterLanguages} - > - - - - {(item: string) => ( - - {item} - - )} - - {t`No matches`} - - - - -
+ + + event.preventDefault()} + aria-label={t`Set language (current: ${label})`} + > + {label} + + + + {copied ? t`Copied` : ""} + + + + items={languageItems} + value={draft} + onValueChange={(next: string) => { + setDraft(next); + setKeyboardHighlightedLanguage(null); + }} + onItemHighlighted={(item, details) => + setKeyboardHighlightedLanguage( + details.reason === "keyboard" ? (item ?? null) : null, + ) + } + itemToStringValue={(item) => item.label} + filter={filterLanguages} + open={isEditing} + className="max-h-[min(16rem,30vh)] [&>div:first-child]:gap-0 [&>div:first-child]:px-3 [&>div:first-child]:py-3 [&>div:first-child]:focus-within:ring-0" + > +
@@ -210,8 +321,8 @@ function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) * `StarterKit.configure({ codeBlock: false })` and add this extension to * the editor's extensions array. */ -export const CodeBlockExtension = CodeBlock.extend({ +export const CodeBlockExtension = CodeBlockLowlight.extend({ addNodeView() { return ReactNodeViewRenderer(CodeBlockNodeView); }, -}); +}).configure({ lowlight: editorLowlight, defaultLanguage: "plaintext" }); diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index b8fd689a28..94840558a1 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -242,6 +242,92 @@ body { --tw-prose-pre-bg: var(--color-kumo-contrast); } +.emdash-code-block { + --emdash-code-background: #f7f7f5; + --emdash-code-foreground: #24292f; + --emdash-code-muted: #57606a; + --emdash-code-keyword: #b8172a; + --emdash-code-string: #0a3069; + --emdash-code-number: #0550ae; + --emdash-code-title: #7545c7; + + background: var(--emdash-code-background); + color: var(--emdash-code-foreground); + caret-color: var(--emdash-code-foreground); + padding-block: 2rem; +} + +[data-mode="dark"] .emdash-code-block { + --emdash-code-background: #202020; + --emdash-code-foreground: #f0f3f6; + --emdash-code-muted: #c9d1d9; + --emdash-code-keyword: #ffc1bb; + --emdash-code-string: #b9ddff; + --emdash-code-number: #a8d5ff; + --emdash-code-title: #e5ccff; +} + +.emdash-code-block code { + background: transparent; + color: inherit; +} + +.emdash-code-block :is(.hljs-comment, .hljs-quote) { + color: var(--emdash-code-muted); +} + +.emdash-code-block + :is(.hljs-keyword, .hljs-literal, .hljs-selector-tag, .hljs-section, .hljs-link, .hljs-deletion) { + color: var(--emdash-code-keyword); +} + +.emdash-code-block + :is(.hljs-string, .hljs-attr, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition) { + color: var(--emdash-code-string); +} + +.emdash-code-block :is(.hljs-number, .hljs-meta) { + color: var(--emdash-code-number); +} + +.emdash-code-block + :is( + .hljs-title, + .hljs-name, + .hljs-type, + .hljs-built_in, + .hljs-selector-id, + .hljs-selector-class + ) { + color: var(--emdash-code-title); +} + +.emdash-code-block-controls { + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.emdash-code-block-node:hover .emdash-code-block-controls, +.emdash-code-block-node:focus-within .emdash-code-block-controls, +.emdash-code-block-controls[data-persistent="true"] { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none), (pointer: coarse) { + .emdash-code-block-controls { + opacity: 1; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .emdash-code-block-controls { + transition: none; + } +} + /** * TipTap placeholder styles */ diff --git a/packages/admin/tests/editor/CodeBlockNode.test.ts b/packages/admin/tests/editor/CodeBlockNode.test.ts index 3ff197c87a..1e63164d49 100644 --- a/packages/admin/tests/editor/CodeBlockNode.test.ts +++ b/packages/admin/tests/editor/CodeBlockNode.test.ts @@ -13,15 +13,19 @@ import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { CodeBlockExtension } from "../../src/components/editor/CodeBlockNode"; describe("CodeBlockExtension", () => { let editor: Editor; + let element: HTMLDivElement; beforeEach(() => { + element = document.createElement("div"); + document.body.append(element); editor = new Editor({ + element, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, @@ -35,6 +39,7 @@ describe("CodeBlockExtension", () => { afterEach(() => { editor.destroy(); + element.remove(); }); it("registers the codeBlock schema node", () => { @@ -74,4 +79,57 @@ describe("CodeBlockExtension", () => { const node = editor.getJSON().content?.find((n) => n.type === "codeBlock"); expect((node as { attrs?: { language?: string } }).attrs?.language).toBe("typescript"); }); + + it.each([ + ["javascript", 'const greeting = "hello";'], + ["dockerfile", "FROM node:22"], + ])("renders syntax tokens for %s", async (language, code) => { + editor.commands.insertContent({ + type: "codeBlock", + attrs: { language }, + content: [{ type: "text", text: code }], + }); + + await vi.waitFor(() => { + expect(element.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0); + }); + + expect(JSON.stringify(editor.getJSON())).not.toContain("hljs-"); + }); + + it.each(["plaintext", "astro", "zig", "custom-language", null, undefined])( + "leaves %s code unhighlighted", + async (language) => { + editor.commands.insertContent({ + type: "codeBlock", + attrs: { language }, + content: [{ type: "text", text: 'const greeting = "hello";' }], + }); + + await vi.waitFor(() => { + expect(element.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0); + }); + }, + ); + + it("updates decorations when the selected language changes", async () => { + editor.commands.insertContent({ + type: "codeBlock", + attrs: { language: "javascript" }, + content: [{ type: "text", text: 'const greeting = "hello";' }], + }); + + await vi.waitFor(() => { + expect(element.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0); + }); + + editor.commands.setNodeSelection(0); + editor.commands.updateAttributes("codeBlock", { language: "astro" }); + + await vi.waitFor(() => { + expect(element.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0); + }); + const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); + expect(node?.content?.[0]?.text).toBe('const greeting = "hello";'); + }); }); diff --git a/packages/admin/tests/editor/PortableTextEditor.code-block.test.ts b/packages/admin/tests/editor/PortableTextEditor.code-block.test.ts new file mode 100644 index 0000000000..4d2bd35725 --- /dev/null +++ b/packages/admin/tests/editor/PortableTextEditor.code-block.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + _portableTextToProsemirror as portableTextToProsemirror, + _prosemirrorToPortableText as prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; + +describe("Portable Text code block conversion", () => { + it("preserves supported and unsupported string languages", () => { + for (const language of ["javascript", "astro"]) { + const proseMirror = portableTextToProsemirror([ + { _type: "code", _key: "code", code: "const value = 1;", language }, + ]); + const codeBlock = proseMirror.content?.[0]; + + expect(codeBlock?.attrs?.language).toBe(language); + expect(prosemirrorToPortableText(proseMirror)[0]).toMatchObject({ + _type: "code", + code: "const value = 1;", + language, + }); + } + }); + + it("treats an invalid non-string language as missing", () => { + const proseMirror = portableTextToProsemirror([ + { + _type: "code", + _key: "code", + code: "const value = 1;", + language: 42, + } as never, + ]); + const codeBlock = proseMirror.content?.[0]; + const serialized = prosemirrorToPortableText(proseMirror)[0]; + + expect(codeBlock?.attrs?.language).toBeNull(); + expect(JSON.stringify(serialized)).not.toContain('"language"'); + }); +}); diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 149833a7a7..163ceb6a99 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -9,6 +9,7 @@ import type { Editor } from "@tiptap/react"; import * as React from "react"; import { describe, it, expect, vi } from "vitest"; +import { userEvent } from "vitest/browser"; import type { PluginBlockDef } from "../../src/components/PortableTextEditor"; import { @@ -1272,3 +1273,142 @@ describe("onChange output shape", () => { expect(listNode).toBeTruthy(); }); }); + +describe("Code block controls", () => { + it("uses a two-action toolbar and selects a language immediately", async () => { + const { screen, editor } = await renderAndGetEditor({ + value: [{ _type: "code", _key: "code", code: "print('hello')", language: "python" }], + }); + + const toolbar = screen.getByRole("toolbar", { name: "Code block actions" }); + await expect.element(toolbar).toBeInTheDocument(); + expect(toolbar.element().querySelectorAll("button")).toHaveLength(2); + await expect.element(toolbar.getByRole("button", { name: "Copy code" })).toBeInTheDocument(); + + await toolbar.getByRole("button", { name: "Set language (current: Python)" }).click(); + const input = screen.getByPlaceholder("Search for a language…"); + await expect.element(input).toBeInTheDocument(); + await screen.getByRole("option", { name: "JavaScript" }).click(); + + await vi.waitFor(() => { + const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); + expect(node?.attrs?.language).toBe("javascript"); + }); + await expect.element(input).not.toBeInTheDocument(); + }); + + it("copies the raw code and exposes copied feedback", async () => { + const clipboardWrite = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const { screen } = await renderAndGetEditor({ + value: [ + { + _type: "code", + _key: "code", + code: "const greeting = 'hello';", + language: "javascript", + }, + ], + }); + + await screen.getByRole("button", { name: "Copy code" }).click(); + await vi.waitFor(() => { + expect(clipboardWrite).toHaveBeenCalledWith("const greeting = 'hello';"); + }); + await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Copied"); + clipboardWrite.mockRestore(); + }); + + it("falls back to document copy when the Clipboard API is unavailable", async () => { + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const copyCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + try { + const { screen } = await renderAndGetEditor({ + value: [ + { + _type: "code", + _key: "code", + code: "const fallback = true;", + language: "javascript", + }, + ], + }); + + await screen.getByRole("button", { name: "Copy code" }).click(); + await vi.waitFor(() => { + expect(copyCommand).toHaveBeenCalledWith("copy"); + }); + await expect.element(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect(document.querySelector("textarea[readonly]")).toBeNull(); + } finally { + copyCommand.mockRestore(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + } + }); + + it("falls back after a rejected Clipboard API write and keeps keyboard focus", async () => { + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const copyCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); + const clipboardWrite = vi.fn().mockRejectedValue(new DOMException("Denied", "NotAllowedError")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWrite }, + }); + try { + const { screen } = await renderAndGetEditor({ + value: [ + { + _type: "code", + _key: "code", + code: "const fallback = true;", + language: "javascript", + }, + ], + }); + + const copyButton = screen.getByRole("button", { name: "Copy code" }); + copyButton.element().focus(); + await copyButton.click(); + await vi.waitFor(() => { + expect(clipboardWrite).toHaveBeenCalledWith("const fallback = true;"); + expect(copyCommand).toHaveBeenCalledWith("copy"); + }); + await expect.element(copyButton).toHaveFocus(); + } finally { + copyCommand.mockRestore(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + } + }); + + it("supports free-form Enter and closes the language search with Escape", async () => { + const { screen, editor } = await renderAndGetEditor({ + value: [{ _type: "code", _key: "code", code: "custom()", language: "plaintext" }], + }); + const languageButton = screen.getByRole("button", { + name: "Set language (current: Plain text)", + }); + + await languageButton.click(); + let input = screen.getByPlaceholder("Search for a language…"); + await input.fill("Custom Language"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => { + const node = editor.getJSON().content?.find((item) => item.type === "codeBlock"); + expect(node?.attrs?.language).toBe("custom-language"); + }); + + await screen.getByRole("button", { name: "Set language (current: custom-language)" }).click(); + input = screen.getByPlaceholder("Search for a language…"); + await userEvent.keyboard("{Escape}"); + await expect.element(input).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index bb55d124ea..e7d688ac4b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -242,6 +242,7 @@ "@tiptap/core": "catalog:", "@tiptap/extension-code": "catalog:", "@tiptap/extension-code-block": "catalog:", + "@tiptap/extension-code-block-lowlight": "catalog:", "@tiptap/extension-focus": "catalog:", "@tiptap/extension-image": "catalog:", "@tiptap/extension-link": "catalog:", @@ -262,6 +263,8 @@ "citty": "^0.2.2", "consola": "^3.4.2", "croner": "^10.0.1", + "highlight.js": "catalog:", + "lowlight": "catalog:", "jose": "^6.1.3", "jpeg-js": "^0.4.4", "jsonc-parser": "catalog:", diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index 4c44724e57..4ff0533b05 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -527,9 +527,10 @@ function convertPTBlock(block: PTBlock): PMNode | null { } if (block._type === "code") { const cb = block as PTBlock & { code?: string; language?: string }; + const language = typeof cb.language === "string" && cb.language.length > 0 ? cb.language : null; return { type: "codeBlock", - attrs: { language: cb.language || null }, + attrs: { language }, content: cb.code ? [{ type: "text", text: cb.code }] : undefined, }; } @@ -2242,6 +2243,7 @@ export function InlinePortableTextEditor({ if (mediaPickerOpen) return; const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null; if (related && e.currentTarget.contains(related)) return; + if (related?.hasAttribute("data-emdash-clipboard-fallback")) return; // Don't save if focus moved to the slash menu (portalled to body) if (related?.closest(".emdash-slash-menu")) return; if (related?.closest(".emdash-media-picker")) return; @@ -2283,6 +2285,285 @@ export function InlinePortableTextEditor({ onSelect={handleMediaSelect} />