From 64691776696ba54fcaac713892a9acb7274117c8 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Mon, 3 Aug 2026 12:38:44 +0200 Subject: [PATCH] Fix silently-disabled syntax highlighting; add copyable CodeEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollupOptions.external` listed `@codemirror/state`, `view`, `lang-json` and `lang-markdown`, but not `@codemirror/language` or `@codemirror/commands`, so the library build inlined those two into extra chunks. Consumers therefore loaded TWO copies of `@codemirror/language`: the language extensions registered their syntax tree against the facets of the copy in the app's node_modules, while `syntaxHighlighting(defaultHighlightStyle)` read the facets of the bundled copy. It found nothing, and every CodeEditor in every consuming app rendered as flat monochrome text — with no error, and with Storybook unaffected because it compiles from `src` against a single copy. - vite.config.ts: externalise every @codemirror/* package. - package.json: declare the optional peers in `peerDependencies` too, not only in `peerDependenciesMeta` (the latter alone pins no version range). - scripts/verify-externals.mjs: fail `npm run build` on any unexpected chunk or relative import in dist — the only place this class of bug is visible. - CodePreview: it never applied `syntaxHighlighting` at all. Now it does. - CodeEditor: new opt-in `copyable` prop (with `copyLabel` /`copiedMessage`) pinning a CopyButton over the top-right. Sticky, not absolute, so it stays put while a long document scrolls in `autoHeight` mode. It copies the pretty-printed document rather than the raw `modelValue`. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 11 ++++- package.json | 14 +++++-- scripts/verify-externals.mjs | 39 ++++++++++++++++++ .../organisms/CodeEditor.stories.ts | 40 ++++++++++++++++++- src/components/organisms/CodeEditor.vue | 31 +++++++++++++- src/components/organisms/CodePreview.vue | 7 +++- tests/interactions.spec.ts | 13 ++++++ vite.config.ts | 11 +++++ 8 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 scripts/verify-externals.mjs diff --git a/package-lock.json b/package-lock.json index dd3db5c..15323f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codebar-ag/storybook", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codebar-ag/storybook", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", @@ -40,6 +40,13 @@ "vue-tsc": "^3.3.7" }, "peerDependencies": { + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.43.0", + "apexcharts": "^4.5.0 || ^5.0.0", "tailwindcss": "^4.0.0", "vue": "^3.5.0" }, diff --git a/package.json b/package.json index cd263bd..675fbcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codebar-ag/storybook", - "version": "1.8.0", + "version": "1.9.0", "description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.", "license": "MIT", "author": "codebar Solutions AG", @@ -25,7 +25,8 @@ "scripts": { "prepare": "npm run build", "dev": "storybook dev -p 6006", - "build": "vite build && npm run build:tokens", + "build": "vite build && npm run build:tokens && npm run verify:externals", + "verify:externals": "node scripts/verify-externals.mjs", "build:tokens": "node -e \"require('node:fs').copyFileSync('src/tokens.css','dist/tokens.css')\"", "build-storybook": "storybook build", "lint": "eslint \"src/**/*.{ts,vue}\"", @@ -39,7 +40,14 @@ }, "peerDependencies": { "tailwindcss": "^4.0.0", - "vue": "^3.5.0" + "vue": "^3.5.0", + "apexcharts": "^4.5.0 || ^5.0.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.43.0" }, "peerDependenciesMeta": { "apexcharts": { diff --git a/scripts/verify-externals.mjs b/scripts/verify-externals.mjs new file mode 100644 index 0000000..f3273b0 --- /dev/null +++ b/scripts/verify-externals.mjs @@ -0,0 +1,39 @@ +// Guards the library build's externalisation contract, which nothing else can. +// +// Storybook and its Playwright suite compile from `src`, so they always see a +// single copy of every dependency — they cannot observe what the PUBLISHED +// bundle does. If a peer package is missing from `rollupOptions.external`, +// Rollup quietly inlines it into an extra chunk and the consumer ends up with +// two instances of it. For `@codemirror/language` that means the parser +// registers its syntax tree against one set of facets while +// `syntaxHighlighting()` reads the other, and every code surface in every +// consuming app renders as flat, unhighlighted text — with no error anywhere. +// +// The observable symptom in `dist` is an extra chunk file plus a relative +// import out of `flows.js`, so both are asserted here. +import { readdirSync, readFileSync } from 'node:fs'; + +const EXPECTED_FILES = ['flows.css', 'flows.js', 'index.d.ts', 'tokens.css']; + +const actual = readdirSync('dist').sort(); +const unexpected = actual.filter((file) => !EXPECTED_FILES.includes(file)); + +if (unexpected.length > 0) { + console.error( + `dist/ has unexpected chunk(s): ${unexpected.join(', ')}\n` + + 'A dependency was bundled instead of externalised. Add it to ' + + "`rollupOptions.external` in vite.config.ts (and to `peerDependencies`).", + ); + process.exit(1); +} + +const bundle = readFileSync('dist/flows.js', 'utf8'); +const relativeImports = [...bundle.matchAll(/(?:from|import\()\s*["'](\.[^"']*)["']/g)].map((match) => match[1]); + +if (relativeImports.length > 0) { + console.error( + `dist/flows.js imports emitted chunk(s): ${[...new Set(relativeImports)].join(', ')}\n` + + 'Every dependency must resolve to a bare specifier so the consuming app supplies one copy.', + ); + process.exit(1); +} diff --git a/src/components/organisms/CodeEditor.stories.ts b/src/components/organisms/CodeEditor.stories.ts index 968cb02..29f1ded 100644 --- a/src/components/organisms/CodeEditor.stories.ts +++ b/src/components/organisms/CodeEditor.stories.ts @@ -1,6 +1,35 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { expect, waitFor } from 'storybook/test'; import CodeEditor from './CodeEditor.vue'; +/** + * Asserts the document is actually SYNTAX HIGHLIGHTED, not merely rendered. + * + * This guards a failure mode with no error signal: if the library build ever + * bundles `@codemirror/language` instead of externalising it, the consumer + * loads two copies of it, the language's syntax tree registers against one + * set of facets and `syntaxHighlighting()` reads the other, and every editor + * silently renders as flat monochrome text. + * + * The check is "some token is painted a colour other than the body text's", + * not "tokens use more than one colour between them": a short JSON document + * may legitimately contain only one *styled* tag kind (`defaultHighlightStyle` + * leaves plain `propertyName` uncoloured), which would make a colour-diversity + * assertion fail on working code. + */ +async function expectHighlighted(canvasElement: HTMLElement): Promise { + await waitFor(async () => { + const content = canvasElement.querySelector('.cm-content'); + await expect(content).not.toBeNull(); + + const tokens = canvasElement.querySelectorAll('.cm-line span'); + await expect(tokens.length).toBeGreaterThan(0); + + const base = getComputedStyle(content as Element).color; + await expect([...tokens].some((token) => getComputedStyle(token).color !== base)).toBe(true); + }); +} + const meta: Meta = { title: 'Organisms/CodeEditor', component: CodeEditor, @@ -19,13 +48,16 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Json: Story = {}; +export const Json: Story = { + play: ({ canvasElement }) => expectHighlighted(canvasElement), +}; export const Markdown: Story = { args: { - modelValue: '# Extraction prompt\n\nSummarize the invoice fields below.', + modelValue: '# Extraction prompt\n\nSummarize the **invoice** fields below.', language: 'markdown', }, + play: ({ canvasElement }) => expectHighlighted(canvasElement), }; export const ReadOnlyEmpty: Story = { @@ -38,3 +70,7 @@ export const ReadOnlyEmpty: Story = { export const AutoHeight: Story = { args: { autoHeight: true, maxHeight: '12rem' }, }; + +export const Copyable: Story = { + args: { copyable: true, readonly: true }, +}; diff --git a/src/components/organisms/CodeEditor.vue b/src/components/organisms/CodeEditor.vue index 7a8fd4e..2fb8e60 100644 --- a/src/components/organisms/CodeEditor.vue +++ b/src/components/organisms/CodeEditor.vue @@ -1,7 +1,8 @@