From 06cd994aa575211fcc2613a657671bd36cca396a Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:00:35 +0100 Subject: [PATCH 01/10] fix(editor): highlight admin code blocks --- .../issue-2361-code-block-highlighting.md | 308 ++++++++++++++++++ e2e/global-setup.ts | 25 ++ e2e/tests/code-block-highlighting.spec.ts | 20 ++ packages/admin/package.json | 3 + .../src/components/PortableTextEditor.tsx | 6 +- .../src/components/editor/CodeBlockNode.tsx | 28 +- packages/admin/src/styles.css | 62 ++++ .../admin/tests/editor/CodeBlockNode.test.ts | 60 +++- .../PortableTextEditor.code-block.test.ts | 40 +++ pnpm-lock.yaml | 50 +++ pnpm-workspace.yaml | 3 + 11 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 docs/technical-specs/issue-2361-code-block-highlighting.md create mode 100644 e2e/tests/code-block-highlighting.spec.ts create mode 100644 packages/admin/tests/editor/PortableTextEditor.code-block.test.ts 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..90a9f3fb0c --- /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`, `foreground`, and `border`. +- 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` | Code-block and 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 | `#f6f8fa` | `#0d1117` | +| Foreground | `#24292f` | `#f0f3f6` | +| Muted | `#57606a` | `#c9d1d9` | +| Keyword | `#b8172a` | `#ffbcb5` | +| 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 values for the admin's internal syntax role variables. The syntax and control foregrounds measure at least APCA `|Lc| 75` and WCAG 2 `4.5:1` against their assigned backgrounds. 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, 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..7f0ea82768 --- /dev/null +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "../fixtures"; + +test.describe("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); + }); +}); 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..e47b4a027d 100644 --- a/packages/admin/src/components/editor/CodeBlockNode.tsx +++ b/packages/admin/src/components/editor/CodeBlockNode.tsx @@ -1,7 +1,7 @@ /** * Code block node with language picker. * - * Wraps the base `@tiptap/extension-code-block` with a React node view that + * Wraps the Lowlight 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 @@ -27,9 +27,11 @@ import { Autocomplete, Button, Popover } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Check, X } from "@phosphor-icons/react"; -import CodeBlock from "@tiptap/extension-code-block"; +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,6 +40,24 @@ import { normalizeLanguage, } from "./codeBlockLanguages"; +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); + }, +}; + function CodeBlockNodeView({ node, updateAttributes, selected }: NodeViewProps) { const { t } = useLingui(); const [isEditing, setIsEditing] = React.useState(false); @@ -210,8 +230,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..65b5a4f1fb 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -242,6 +242,68 @@ body { --tw-prose-pre-bg: var(--color-kumo-contrast); } +.emdash-code-block { + --emdash-code-background: #f6f8fa; + --emdash-code-foreground: #24292f; + --emdash-code-muted: #57606a; + --emdash-code-keyword: #b8172a; + --emdash-code-string: #0a3069; + --emdash-code-number: #0550ae; + --emdash-code-title: #7545c7; + --emdash-code-border: #7d8590; + + border: 1px solid var(--emdash-code-border); + background: var(--emdash-code-background); + color: var(--emdash-code-foreground); + caret-color: var(--emdash-code-foreground); +} + +[data-mode="dark"] .emdash-code-block { + --emdash-code-background: #0d1117; + --emdash-code-foreground: #f0f3f6; + --emdash-code-muted: #c9d1d9; + --emdash-code-keyword: #ffbcb5; + --emdash-code-string: #b9ddff; + --emdash-code-number: #a8d5ff; + --emdash-code-title: #e5ccff; + --emdash-code-border: #6e7681; +} + +.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); +} + /** * 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9a61524377..139417c3c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ catalogs: '@tiptap/extension-code-block': specifier: ^3.20.0 version: 3.20.0 + '@tiptap/extension-code-block-lowlight': + specifier: 3.20.0 + version: 3.20.0 '@tiptap/extension-collaboration': specifier: ^3.20.0 version: 3.27.0 @@ -240,6 +243,9 @@ catalogs: chokidar: specifier: ^5.0.0 version: 5.0.0 + highlight.js: + specifier: 11.11.2 + version: 11.11.2 image-size: specifier: 2.0.2 version: 2.0.2 @@ -249,6 +255,9 @@ catalogs: kysely: specifier: ^0.29.0 version: 0.29.2 + lowlight: + specifier: ^3.3.0 + version: 3.3.0 publint: specifier: 0.3.17 version: 0.3.17 @@ -1189,6 +1198,9 @@ importers: '@tiptap/extension-code-block': specifier: 'catalog:' version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-code-block-lowlight': + specifier: 'catalog:' + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(highlight.js@11.11.2)(lowlight@3.3.0) '@tiptap/extension-collaboration': specifier: 'catalog:' version: 3.27.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@tiptap/y-tiptap@3.0.5(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) @@ -1264,6 +1276,12 @@ importers: dompurify: specifier: ^3.3.2 version: 3.3.2 + highlight.js: + specifier: 'catalog:' + version: 11.11.2 + lowlight: + specifier: 'catalog:' + version: 3.3.0 marked: specifier: ^17.0.3 version: 17.0.3 @@ -7532,6 +7550,15 @@ packages: peerDependencies: '@tiptap/extensions': ^3.20.0 + '@tiptap/extension-code-block-lowlight@3.20.0': + resolution: {integrity: sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-code-block': ^3.20.0 + '@tiptap/pm': ^3.20.0 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@3.20.0': resolution: {integrity: sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==} peerDependencies: @@ -9495,6 +9522,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + hono-openapi@1.3.0: resolution: {integrity: sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig==} peerDependencies: @@ -10005,6 +10036,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -18033,6 +18067,14 @@ snapshots: dependencies: '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-code-block-lowlight@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(highlight.js@11.11.2)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-code-block': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + highlight.js: 11.11.2 + lowlight: 3.3.0 + '@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': dependencies: '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) @@ -20813,6 +20855,8 @@ snapshots: highlight.js@10.7.3: {} + highlight.js@11.11.2: {} + hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.1))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3): dependencies: '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(quansync@0.2.11)(typebox@1.3.7)(valibot@1.4.1(typescript@6.0.3))(zod-to-json-schema@3.25.1(zod@4.4.1))(zod@4.4.1) @@ -21286,6 +21330,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.2 + lru-cache@10.4.3: {} lru-cache@11.2.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f31b0b4e4..f6d5f88484 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -118,6 +118,7 @@ catalog: "@tiptap/extension-character-count": ^3.20.0 "@tiptap/extension-code": ^3.20.0 "@tiptap/extension-code-block": ^3.20.0 + "@tiptap/extension-code-block-lowlight": 3.20.0 "@tiptap/extension-collaboration": ^3.20.0 "@tiptap/extension-drag-handle": ^3.20.0 "@tiptap/extension-drag-handle-react": ^3.20.0 @@ -151,9 +152,11 @@ catalog: astro-iconset: ^0.0.4 better-sqlite3: ^12.8.0 chokidar: ^5.0.0 + highlight.js: 11.11.2 image-size: 2.0.2 jsonc-parser: ^3.3.1 kysely: ^0.29.0 + lowlight: ^3.3.0 publint: 0.3.17 react: 19.2.4 react-dom: 19.2.4 From 0e93ff586f187f52f0f34004f305fda302b3860c Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:28:36 +0100 Subject: [PATCH 02/10] fix(editor): highlight inline code blocks --- .changeset/bright-code-blocks.md | 6 ++ .../content/docs/guides/querying-content.mdx | 52 +++++++++++ e2e/tests/code-block-highlighting.spec.ts | 73 ++++++++++++++- packages/core/package.json | 3 + .../components/InlinePortableTextEditor.tsx | 91 ++++++++++++++++++- .../core/src/components/inline-code-block.tsx | 42 ++++++--- .../inline-portable-text-code-block.test.ts | 67 ++++++++++++++ pnpm-lock.yaml | 9 ++ 8 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 .changeset/bright-code-blocks.md create mode 100644 packages/core/tests/unit/components/inline-portable-text-code-block.test.ts diff --git a/.changeset/bright-code-blocks.md b/.changeset/bright-code-blocks.md new file mode 100644 index 0000000000..f13d0204d5 --- /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 and readable in light and dark appearances. diff --git a/docs/src/content/docs/guides/querying-content.mdx b/docs/src/content/docs/guides/querying-content.mdx index 03718063d4..0a76e1ccb2 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` | Code-block and 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: #f6f8fa; + --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: #0d1117; + --emdash-inline-code-foreground: #f0f3f6; + --emdash-inline-code-muted: #c9d1d9; + --emdash-inline-code-keyword: #ffbcb5; + --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/e2e/tests/code-block-highlighting.spec.ts b/e2e/tests/code-block-highlighting.spec.ts index 7f0ea82768..6fede2b6e6 100644 --- a/e2e/tests/code-block-highlighting.spec.ts +++ b/e2e/tests/code-block-highlighting.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures"; -test.describe("Code block highlighting", () => { +test.describe("Admin code block highlighting", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); await admin.goToContent("posts"); @@ -18,3 +18,74 @@ test.describe("Code block highlighting", () => { await expect(codeBlocks.nth(1).locator('span[class*="hljs-"]')).toHaveCount(0); }); }); + +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("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 page.emulateMedia({ colorScheme: "dark" }); + const darkBackground = await codeBlock.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(lightBackground).not.toBe(darkBackground); + + 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); + }); +}); 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..477f4173c1 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, }; } @@ -2283,6 +2284,94 @@ export function InlinePortableTextEditor({ onSelect={handleMediaSelect} />