Skip to content

Commit 4e1f88f

Browse files
authored
Merge pull request #7 from codebar-ag/fix/codemirror-language-duplication
Fix silently-disabled syntax highlighting; add copyable CodeEditor
2 parents 635d8bb + 6469177 commit 4e1f88f

8 files changed

Lines changed: 157 additions & 9 deletions

File tree

package-lock.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codebar-ag/storybook",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
55
"license": "MIT",
66
"author": "codebar Solutions AG",
@@ -25,7 +25,8 @@
2525
"scripts": {
2626
"prepare": "npm run build",
2727
"dev": "storybook dev -p 6006",
28-
"build": "vite build && npm run build:tokens",
28+
"build": "vite build && npm run build:tokens && npm run verify:externals",
29+
"verify:externals": "node scripts/verify-externals.mjs",
2930
"build:tokens": "node -e \"require('node:fs').copyFileSync('src/tokens.css','dist/tokens.css')\"",
3031
"build-storybook": "storybook build",
3132
"lint": "eslint \"src/**/*.{ts,vue}\"",
@@ -39,7 +40,14 @@
3940
},
4041
"peerDependencies": {
4142
"tailwindcss": "^4.0.0",
42-
"vue": "^3.5.0"
43+
"vue": "^3.5.0",
44+
"apexcharts": "^4.5.0 || ^5.0.0",
45+
"@codemirror/commands": "^6.10.0",
46+
"@codemirror/lang-json": "^6.0.0",
47+
"@codemirror/lang-markdown": "^6.5.0",
48+
"@codemirror/language": "^6.12.0",
49+
"@codemirror/state": "^6.7.0",
50+
"@codemirror/view": "^6.43.0"
4351
},
4452
"peerDependenciesMeta": {
4553
"apexcharts": {

scripts/verify-externals.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Guards the library build's externalisation contract, which nothing else can.
2+
//
3+
// Storybook and its Playwright suite compile from `src`, so they always see a
4+
// single copy of every dependency — they cannot observe what the PUBLISHED
5+
// bundle does. If a peer package is missing from `rollupOptions.external`,
6+
// Rollup quietly inlines it into an extra chunk and the consumer ends up with
7+
// two instances of it. For `@codemirror/language` that means the parser
8+
// registers its syntax tree against one set of facets while
9+
// `syntaxHighlighting()` reads the other, and every code surface in every
10+
// consuming app renders as flat, unhighlighted text — with no error anywhere.
11+
//
12+
// The observable symptom in `dist` is an extra chunk file plus a relative
13+
// import out of `flows.js`, so both are asserted here.
14+
import { readdirSync, readFileSync } from 'node:fs';
15+
16+
const EXPECTED_FILES = ['flows.css', 'flows.js', 'index.d.ts', 'tokens.css'];
17+
18+
const actual = readdirSync('dist').sort();
19+
const unexpected = actual.filter((file) => !EXPECTED_FILES.includes(file));
20+
21+
if (unexpected.length > 0) {
22+
console.error(
23+
`dist/ has unexpected chunk(s): ${unexpected.join(', ')}\n` +
24+
'A dependency was bundled instead of externalised. Add it to ' +
25+
"`rollupOptions.external` in vite.config.ts (and to `peerDependencies`).",
26+
);
27+
process.exit(1);
28+
}
29+
30+
const bundle = readFileSync('dist/flows.js', 'utf8');
31+
const relativeImports = [...bundle.matchAll(/(?:from|import\()\s*["'](\.[^"']*)["']/g)].map((match) => match[1]);
32+
33+
if (relativeImports.length > 0) {
34+
console.error(
35+
`dist/flows.js imports emitted chunk(s): ${[...new Set(relativeImports)].join(', ')}\n` +
36+
'Every dependency must resolve to a bare specifier so the consuming app supplies one copy.',
37+
);
38+
process.exit(1);
39+
}

src/components/organisms/CodeEditor.stories.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import type { Meta, StoryObj } from '@storybook/vue3-vite';
2+
import { expect, waitFor } from 'storybook/test';
23
import CodeEditor from './CodeEditor.vue';
34

5+
/**
6+
* Asserts the document is actually SYNTAX HIGHLIGHTED, not merely rendered.
7+
*
8+
* This guards a failure mode with no error signal: if the library build ever
9+
* bundles `@codemirror/language` instead of externalising it, the consumer
10+
* loads two copies of it, the language's syntax tree registers against one
11+
* set of facets and `syntaxHighlighting()` reads the other, and every editor
12+
* silently renders as flat monochrome text.
13+
*
14+
* The check is "some token is painted a colour other than the body text's",
15+
* not "tokens use more than one colour between them": a short JSON document
16+
* may legitimately contain only one *styled* tag kind (`defaultHighlightStyle`
17+
* leaves plain `propertyName` uncoloured), which would make a colour-diversity
18+
* assertion fail on working code.
19+
*/
20+
async function expectHighlighted(canvasElement: HTMLElement): Promise<void> {
21+
await waitFor(async () => {
22+
const content = canvasElement.querySelector('.cm-content');
23+
await expect(content).not.toBeNull();
24+
25+
const tokens = canvasElement.querySelectorAll('.cm-line span');
26+
await expect(tokens.length).toBeGreaterThan(0);
27+
28+
const base = getComputedStyle(content as Element).color;
29+
await expect([...tokens].some((token) => getComputedStyle(token).color !== base)).toBe(true);
30+
});
31+
}
32+
433
const meta: Meta<typeof CodeEditor> = {
534
title: 'Organisms/CodeEditor',
635
component: CodeEditor,
@@ -19,13 +48,16 @@ const meta: Meta<typeof CodeEditor> = {
1948
export default meta;
2049
type Story = StoryObj<typeof CodeEditor>;
2150

22-
export const Json: Story = {};
51+
export const Json: Story = {
52+
play: ({ canvasElement }) => expectHighlighted(canvasElement),
53+
};
2354

2455
export const Markdown: Story = {
2556
args: {
26-
modelValue: '# Extraction prompt\n\nSummarize the invoice fields below.',
57+
modelValue: '# Extraction prompt\n\nSummarize the **invoice** fields below.',
2758
language: 'markdown',
2859
},
60+
play: ({ canvasElement }) => expectHighlighted(canvasElement),
2961
};
3062

3163
export const ReadOnlyEmpty: Story = {
@@ -38,3 +70,7 @@ export const ReadOnlyEmpty: Story = {
3870
export const AutoHeight: Story = {
3971
args: { autoHeight: true, maxHeight: '12rem' },
4072
};
73+
74+
export const Copyable: Story = {
75+
args: { copyable: true, readonly: true },
76+
};

src/components/organisms/CodeEditor.vue

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup lang="ts">
2-
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
2+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
33
import type { EditorView as EditorViewType } from '@codemirror/view';
44
import { createCodeMirrorTheme } from '../../helpers/codeMirrorTheme';
5+
import CopyButton from '../molecules/CopyButton.vue';
56
67
// CodeMirror is an OPTIONAL peer dependency: it is imported lazily so apps
78
// that never render an editor don't pay for the bundle (same convention as
@@ -16,6 +17,10 @@ const props = withDefaults(
1617
placeholder?: string | null;
1718
autoHeight?: boolean;
1819
maxHeight?: string | null;
20+
/** Pins a copy-to-clipboard button over the top-right of the editor. Opt-in, so existing surfaces keep their chrome. */
21+
copyable?: boolean;
22+
copyLabel?: string;
23+
copiedMessage?: string;
1924
}>(),
2025
{
2126
modelValue: '',
@@ -24,6 +29,9 @@ const props = withDefaults(
2429
placeholder: null,
2530
autoHeight: false,
2631
maxHeight: null,
32+
copyable: false,
33+
copyLabel: 'Copy to clipboard',
34+
copiedMessage: 'Copied to clipboard',
2735
},
2836
);
2937
@@ -44,6 +52,10 @@ function formatValue(raw: string): string {
4452
}
4553
}
4654
55+
// Copies what the operator can actually see — the pretty-printed document,
56+
// not the raw (often single-line) `modelValue` handed in by the caller.
57+
const copyValue = computed(() => formatValue(props.modelValue ?? ''));
58+
4759
async function loadLanguage(language: string) {
4860
if (language === 'markdown') {
4961
const { markdown, markdownLanguage } = await import('@codemirror/lang-markdown');
@@ -140,6 +152,23 @@ onBeforeUnmount(() => view?.destroy());
140152
:class="autoHeight ? 'overflow-y-auto' : 'h-full min-h-0 overflow-hidden'"
141153
:style="autoHeight && maxHeight ? { maxHeight } : undefined"
142154
>
155+
<!--
156+
Sticky rather than absolute, and zero-height so it claims no layout: in
157+
`autoHeight` mode THIS element is the scroll container, and an absolutely
158+
positioned child would scroll out of sight on any document longer than
159+
the visible box.
160+
-->
161+
<div
162+
v-if="copyable && modelValue"
163+
class="sticky top-0 z-10 flex h-0 justify-end"
164+
>
165+
<CopyButton
166+
:value="copyValue"
167+
:label="copyLabel"
168+
:copied-message="copiedMessage"
169+
class="mt-1.5 mr-1.5 rounded-control border border-line bg-surface/90 text-dim backdrop-blur-sm hover:text-ink focus-visible:ring-accent/50"
170+
/>
171+
</div>
143172
<div
144173
v-if="!modelValue && readonly && placeholder"
145174
:class="autoHeight ? 'flex min-h-16 items-center justify-center' : 'pointer-events-none absolute inset-0 flex items-center justify-center'"

src/components/organisms/CodePreview.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ async function mount(): Promise<void> {
5555
return;
5656
}
5757
58-
const [{ EditorState }, EditorViewModule, lang] = await Promise.all([
58+
const [{ EditorState }, EditorViewModule, { syntaxHighlighting, defaultHighlightStyle }, lang] = await Promise.all([
5959
import('@codemirror/state'),
6060
import('@codemirror/view'),
61+
import('@codemirror/language'),
6162
loadLanguage(props.language),
6263
]);
6364
const { EditorView, lineNumbers } = EditorViewModule;
@@ -76,6 +77,10 @@ async function mount(): Promise<void> {
7677
EditorView.lineWrapping,
7778
lineNumbers(),
7879
lang,
80+
// Same highlighter as CodeEditor — a preview surface that
81+
// renders code as flat grey text is the one thing it exists
82+
// not to do.
83+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
7984
// Shared theme (same as CodeEditor) plus this component's own
8085
// maxHeight cap, which only makes sense for a preview surface.
8186
createCodeMirrorTheme(EditorViewModule, { autoHeight: true }),

tests/interactions.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,16 @@ test('copy button writes to the clipboard and toasts', async ({ page, context })
2828
await page.getByRole('button', { name: 'Copy to clipboard' }).first().click();
2929
await expect(page.getByText('Copied to clipboard').first()).toBeVisible();
3030
});
31+
32+
test('a copyable code editor copies the document it displays', async ({ page, context }) => {
33+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
34+
await gotoStory(page, 'organisms-codeeditor--copyable');
35+
await expect(page.locator('.cm-content')).toBeVisible();
36+
await page.getByRole('button', { name: 'Copy to clipboard' }).click();
37+
38+
// Only the clipboard write is asserted here — the story renders no Toaster,
39+
// and the toast itself is already covered by the CopyButton test above.
40+
// The pretty-printed document, not whatever shape the caller passed in.
41+
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
42+
expect(clipboard).toBe('{\n "vendor": "string",\n "invoice_number": "string"\n}');
43+
});

vite.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,22 @@ export default defineConfig({
3030
formats: ['es'],
3131
},
3232
rollupOptions: {
33+
// EVERY @codemirror/* package must be listed here, not just the
34+
// ones imported by name in a component. `@codemirror/language`
35+
// owns the facets that tie a language's syntax tree to the
36+
// highlighter; if it is bundled while `lang-json`/`lang-markdown`
37+
// stay external, the consumer ends up with two instances of it —
38+
// the parser registers its tree against one set of facets and
39+
// `syntaxHighlighting()` reads the other, so code renders
40+
// completely unhighlighted with no error anywhere.
41+
// `@codemirror/commands` is the same hazard for the default keymap.
3342
external: [
3443
'vue',
3544
'apexcharts',
3645
'@codemirror/state',
3746
'@codemirror/view',
47+
'@codemirror/language',
48+
'@codemirror/commands',
3849
'@codemirror/lang-json',
3950
'@codemirror/lang-markdown',
4051
],

0 commit comments

Comments
 (0)