dsh-better-display 0.3.9 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.en.md +5 -5
  3. package/README.md +5 -5
  4. package/compat/harness-rc2.json +17 -13
  5. package/docs/extension-points.md +36 -0
  6. package/docs/official-rendering-bridge.md +1 -1
  7. package/lib/client.js +2405 -2411
  8. package/lib/client.js.map +1 -1
  9. package/lib/dsh-better-display.js +9 -18
  10. package/lib/types/client/Blocks.js +4 -4
  11. package/lib/types/client/Blocks.js.map +1 -1
  12. package/lib/types/client/OfficialContent.js +6 -7
  13. package/lib/types/client/OfficialContent.js.map +1 -1
  14. package/lib/types/client/Reader.d.ts.map +1 -1
  15. package/lib/types/client/Reader.js +5 -6
  16. package/lib/types/client/Reader.js.map +1 -1
  17. package/lib/types/client/composer-seat.d.ts +24 -0
  18. package/lib/types/client/composer-seat.d.ts.map +1 -0
  19. package/lib/types/client/composer-seat.js +23 -0
  20. package/lib/types/client/composer-seat.js.map +1 -0
  21. package/lib/types/client/official-slots.d.ts +15 -3
  22. package/lib/types/client/official-slots.d.ts.map +1 -1
  23. package/lib/types/client/official-slots.js +37 -4
  24. package/lib/types/client/official-slots.js.map +1 -1
  25. package/lib/types/client/primitive-labels.d.ts.map +1 -1
  26. package/lib/types/client/primitive-labels.js +1 -0
  27. package/lib/types/client/primitive-labels.js.map +1 -1
  28. package/package.json +21 -20
  29. package/scripts/check-harness-compat.mjs +4 -2
  30. package/scripts/link-harness-dependencies.mjs +48 -11
  31. package/src/client/Blocks.tsx +5 -5
  32. package/src/client/OfficialContent.tsx +7 -7
  33. package/src/client/Reader.module.css +11 -0
  34. package/src/client/Reader.tsx +5 -6
  35. package/src/client/composer-seat.ts +28 -0
  36. package/src/client/official-slots.tsx +38 -5
  37. package/src/client/primitive-labels.ts +1 -0
  38. package/tests/browser-official-bridge.tsx +2 -1
  39. package/tests/fixtures/reader-user-bubble.tsx +37 -15
  40. package/tests/reader-composer-seat.test.ts +61 -0
  41. package/tests/reader-official-users.test.ts +1 -1
  42. package/tests/reader-theme-overlap.test.ts +1 -1
  43. package/tests/reader-user-bubble.test.ts +1 -0
  44. package/tests/reasoning-block-key.test.ts +129 -0
  45. package/tests/stock-install.test.ts +10 -6
  46. package/tsdown.config.ts +12 -3
@@ -21,6 +21,7 @@ export const readBlockLabels: ReadBlockLabels = {
21
21
  };
22
22
 
23
23
  export const terminalBlockLabels: TerminalBlockLabels = {
24
+ commandLine: line => `命令第 ${line} 行`,
24
25
  signal: signal => `信号 ${signal}`, exitCode: code => `退出码 ${code}`, noExitCode: '无退出码',
25
26
  running: '执行中', failed: '失败', done: '已完成', copy: '复制', copied: '已复制',
26
27
  noOutput: '没有输出', collapseAria: '收起命令输出', collapse: '收起',
@@ -78,7 +78,8 @@ slots.register({ name: 'root', children: {
78
78
  'tool.call.toolview': { kind: 'keyed', scope: 'session' },
79
79
  'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
80
80
  'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: { turnData: (_s: any, value: any) => () => value } } },
81
- 'conversation.message.images': { kind: 'single', scope: 'session' },
81
+ 'conversation.chat.message.images': { kind: 'single', scope: 'session' },
82
+ 'conversation.chat.user.images': { kind: 'single', scope: 'session' },
82
83
  'conversation.input.overlay': { kind: 'list', scope: 'session' },
83
84
  'fixture.reader': { kind: 'single', scope: 'session' },
84
85
  } }, ({ SessionProvider, renderSlot }: any) => <SessionProvider>
@@ -4,6 +4,7 @@ import { flushSync } from 'react-dom';
4
4
  import { SlotRegistry } from '@fixture/registry';
5
5
  import { createSlotRenderer } from '@fixture/renderer';
6
6
  import { UserMessageNodeView } from '@fixture/user-message';
7
+ import { UserContent } from '@fixture/user-content';
7
8
  import { Reader } from '../../src/client/Reader.js';
8
9
  import { createReaderStore } from '../../src/client/store.js';
9
10
  import { OFFICIAL_SEATS, OFFICIAL_SLOTS, installOfficialSlots, officialChildren } from '../../src/client/official-slots.js';
@@ -81,16 +82,20 @@ function NativeColumn({ renderSlot, kind, text }: {
81
82
  }) {
82
83
  const node = messageNode(kind, text);
83
84
  const owner = {
84
- cwd: '/fixture', openFile: () => {}, openSkill: () => {}, forkAt: () => {},
85
+ cwd: '/fixture', openFile: () => {}, forkAt: () => {},
85
86
  inspectCall: () => {}, loadImage: async () => null,
86
- renderMessageImages: () => null, fileMentions: () => undefined, node,
87
+ fileMentions: () => undefined, node,
87
88
  };
88
89
  return (
89
90
  <div data-native={kind} style={{ width: '100%', maxWidth: 'var(--dsh-chat-content-width, 748px)', display: 'flex', flexDirection: 'column' }}>
90
91
  <div style={{ minWidth: 0 }}>
91
92
  {renderSlot(OFFICIAL_SLOTS.nodes, owner, {
92
93
  entryKey: kind,
93
- hookContext: { turnData: node.location.turn.data, disclosureReset: { getSnapshot: () => 0, subscribe: () => () => {}, set: () => {} } },
94
+ hookContext: {
95
+ turnData: node.location.turn.data,
96
+ disclosureReset: { getSnapshot: () => 0, subscribe: () => () => {}, set: () => {} },
97
+ useGroupAction: () => ({ current: null }),
98
+ },
94
99
  fallback: null,
95
100
  })}
96
101
  </div>
@@ -98,22 +103,33 @@ function NativeColumn({ renderSlot, kind, text }: {
98
103
  );
99
104
  }
100
105
 
106
+ function paddedTextBox(scope: ParentNode | null): Element | null {
107
+ if (!scope) return null;
108
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
109
+ let node: Node | null;
110
+ while ((node = walker.nextNode())) {
111
+ if (!node.textContent?.trim()) continue;
112
+ const parent = node.parentElement;
113
+ if (!parent || parent.closest('button')) continue;
114
+ let element: HTMLElement | null = parent;
115
+ while (element && element !== scope) {
116
+ if (parseFloat(getComputedStyle(element).paddingTop) > 0) return element;
117
+ element = element.parentElement;
118
+ }
119
+ return parent;
120
+ }
121
+ return null;
122
+ }
123
+
101
124
  function boxOf(root: ParentNode | null, kind: 'user' | 'steering' | 'pending') {
102
125
  const scope = kind === 'pending'
103
126
  ? root?.querySelector('[data-reader-pending-submission]')
104
127
  : root?.querySelector(`[data-reader-key="${kind}-key"]`);
105
- if (!scope) return null;
106
- if (kind === 'pending') {
107
- return [...scope.children].find(element => element instanceof HTMLElement && element.querySelector('button') === null) ?? null;
108
- }
109
- const slot = scope.querySelector('[data-slot]');
110
- return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
128
+ return paddedTextBox(scope ?? null);
111
129
  }
112
130
 
113
131
  function nativeBox(frame: ParentNode, kind: 'user' | 'steering') {
114
- const column = frame.querySelector(`[data-native="${kind}"]`);
115
- const slot = column?.querySelector('[data-slot]');
116
- return slot?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
132
+ return paddedTextBox(frame.querySelector(`[data-native="${kind}"]`));
117
133
  }
118
134
 
119
135
  function measure(element: Element | null) {
@@ -146,16 +162,15 @@ async function verify() {
146
162
  {...props as never}
147
163
  t={(props.t as ((key: string) => string) | undefined) ?? ((key: string) => key)}
148
164
  openFile={(props.openFile as (() => void) | undefined) ?? (() => {})}
149
- openSkill={(props.openSkill as (() => void) | undefined) ?? (() => {})}
150
- renderMessageImages={(props.renderMessageImages as (() => null) | undefined) ?? (() => null)}
151
165
  />
152
166
  );
153
167
  slots.register({ name: 'root', children: {
154
168
  'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: { hooks: {
155
169
  turnData: (_: unknown, context: { turnData?: unknown }) => () => context.turnData,
156
170
  disclosure: (_: unknown, context: { disclosureReset?: { getSnapshot(): number } }) => () => context.disclosureReset,
171
+ groupAction: (_: unknown, context: { useGroupAction?: unknown }) => context.useGroupAction,
157
172
  } } },
158
- 'conversation.message.images': { kind: 'single', scope: 'session' },
173
+ 'conversation.chat.message.images': { kind: 'single', scope: 'session' },
159
174
  'fixture.reader': { kind: 'single', scope: 'session' },
160
175
  } }, ({ SessionProvider, renderSlot }: any) => (
161
176
  <SessionProvider>
@@ -182,6 +197,13 @@ async function verify() {
182
197
  ))}
183
198
  </>
184
199
  ));
200
+ slots.registerFactory({
201
+ name: 'conversation.chat.user.content', scope: 'session', locale: 'chat',
202
+ children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } },
203
+ }, UserContent);
204
+ slots.registerFactory({
205
+ name: 'conversation.user.text', scope: 'session', locale: 'chat',
206
+ }, ({ text }: { text: string }) => <>{text}</>);
185
207
  slots.register({ name: 'conversation.chat.node', key: 'user', locale: 'chat' }, HostUser);
186
208
  slots.register({ name: 'conversation.chat.node', key: 'steering', locale: 'chat' }, HostUser);
187
209
  const stopMirror = installOfficialSlots(ctx);
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { test } from 'vitest';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { hostComposerUsable, READER_COMPOSER_RESTORE } from '../src/client/composer-seat.js';
7
+ import { ReaderEntryPolicy, readerEntryRequested } from '../src/client/entry-policy.js';
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+
11
+ /** Specificity tuple [ids, attributes-and-pseudos, elements] for the two selectors this fix compares. */
12
+ function specificity(selector: string): [number, number, number] {
13
+ const expanded = selector.replace(/:has\(([^)]*)\)/g, ' $1 ');
14
+ const ids = expanded.match(/#[\w-]+/g)?.length ?? 0;
15
+ const attrs = expanded.match(/\[[^\]]+\]/g)?.length ?? 0;
16
+ const without = expanded.replace(/\[[^\]]+\]/g, ' ').replace(/:[^\s#.[]+/g, ' ').replace(/#[\w-]+/g, ' ');
17
+ const elements = without.match(/[a-zA-Z][\w-]*/g)?.length ?? 0;
18
+ return [ids, attrs, elements];
19
+ }
20
+
21
+ const SKIN_HIDE = 'body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-composer-hidden] [data-composer-seat]';
22
+
23
+ test('a new session lands on reader and the host composer stays usable there', () => {
24
+ const policy = new ReaderEntryPolicy(false);
25
+ assert.equal(policy.select(null), 'reader');
26
+ assert.equal(policy.select(undefined), 'reader');
27
+ assert.equal(hostComposerUsable({ skinHidesSeat: false, readerMounted: true }), true);
28
+ assert.equal(hostComposerUsable({ skinHidesSeat: true, readerMounted: true }), true);
29
+ });
30
+
31
+ test('?reader=1 still enters reading once and does not cover the composer', () => {
32
+ assert.equal(readerEntryRequested('?reader=1'), true);
33
+ let consumed = 0;
34
+ const policy = new ReaderEntryPolicy(true, () => { consumed++; });
35
+ assert.equal(policy.select('chat'), 'reader');
36
+ assert.equal(consumed, 1);
37
+ assert.equal(policy.select('chat'), null);
38
+ assert.equal(hostComposerUsable({ skinHidesSeat: true, readerMounted: true }), true);
39
+ });
40
+
41
+ test('leaving the reader tab does not keep forcing the composer visible', () => {
42
+ const policy = new ReaderEntryPolicy(false);
43
+ assert.equal(policy.select('chat'), null);
44
+ assert.equal(policy.select('trajectory'), null);
45
+ assert.equal(policy.select('reader'), null);
46
+ assert.equal(hostComposerUsable({ skinHidesSeat: true, readerMounted: false }), false);
47
+ assert.equal(hostComposerUsable({ skinHidesSeat: false, readerMounted: false }), true);
48
+ });
49
+
50
+ test('the reader stylesheet outranks the skin hide and the bundle ships it', () => {
51
+ const css = readFileSync(resolve(root, 'src/client/Reader.module.css'), 'utf8');
52
+ const reader = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
53
+ assert.match(reader, /data-dsh-better-display=/);
54
+ assert.match(css, new RegExp(`:global\\(${READER_COMPOSER_RESTORE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
55
+ assert.match(css, /display:\s*flex\s*!important/);
56
+ const ours = specificity(READER_COMPOSER_RESTORE);
57
+ const skin = specificity(SKIN_HIDE);
58
+ assert.ok(ours[0] > skin[0] || ours[1] > skin[1] || (ours[1] === skin[1] && ours[2] > skin[2]), `${ours} should beat ${skin}`);
59
+ const client = readFileSync(resolve(root, 'lib/client.js'), 'utf8');
60
+ assert.match(client, /html body\[data-dsh-claude-style\]\[data-dsh-claude-composer-active\] \[data-dsh-claude-composer-hidden\]:has\(\[data-dsh-better-display\]\) \[data-composer-seat\]\{display:flex!important\}/);
61
+ });
@@ -26,7 +26,7 @@ test('official user output stays inside the cluster that carries the host width'
26
26
  assert.doesNotMatch(css, /\[data-slot="conversation\.chat\.node"\]/);
27
27
  });
28
28
 
29
- test('headless Reader users retain official renderers, anchors and empty-key fallback', { skip }, async () => {
29
+ test('headless Reader users retain official renderers, anchors and empty-key fallback', { skip, timeout: 15_000 }, async () => {
30
30
  const root = resolve(import.meta.dirname, '..');
31
31
  const require = createRequire(join(root, 'package.json'));
32
32
  const { build } = createRequire(require.resolve('tsx'))('esbuild');
@@ -70,7 +70,7 @@ function pngPixel(buffer: Buffer, x: number, y: number) {
70
70
  return [pixel?.[start] ?? 0, pixel?.[start + 1] ?? 0, pixel?.[start + 2] ?? 0];
71
71
  }
72
72
 
73
- test('enlarged theme tokens do not let the answer heading share a lane', { skip }, async () => {
73
+ test('enlarged theme tokens do not let the answer heading share a lane', { skip, timeout: 15_000 }, async () => {
74
74
  const root = resolve(import.meta.dirname, '..');
75
75
  const files = new Map([
76
76
  ['/Reader.module.css', readFileSync(join(root, 'src/client/Reader.module.css'))],
@@ -48,6 +48,7 @@ test('reader user and steering bubbles match the host column cap', { skip, timeo
48
48
  '@fixture/registry': join(harness, 'packages/client/ui-renderer/src/client/registry.ts'),
49
49
  '@fixture/renderer': join(harness, 'packages/client/ui-renderer/src/client/scoped-slots.tsx'),
50
50
  '@fixture/user-message': join(harness, 'packages/client/ui-chat/src/client/chat/MessageItem.tsx'),
51
+ '@fixture/user-content': join(harness, 'packages/client/ui-chat/src/client/chat/UserContent.tsx'),
51
52
  '@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
52
53
  '@deepseek-ai/dsh-client-ui-slots': join(harness, 'packages/client/ui-slots/src/index.ts'),
53
54
  '@deepseek-ai/dsh-client-store': join(harness, 'packages/client/store/src/index.ts'),
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert/strict';
2
+ import type { ReactElement, ReactNode } from 'react';
3
+ import { test, vi } from 'vitest';
4
+ import { Blocks } from '../src/client/Blocks.js';
5
+ import type { BlockRenderProps } from '../src/client/types.js';
6
+ import {
7
+ createTextTransformRegistry,
8
+ installTextTransformRegistry,
9
+ type DisplayTextRequest,
10
+ } from '../src/client/text-transform.js';
11
+
12
+ // Inspect the real component paths without a browser or Harness runtime. The
13
+ // fixed useId represents one mounted Blocks instance, not a remount test.
14
+ vi.mock('react', async importOriginal => {
15
+ const react = await importOriginal<typeof import('react')>();
16
+ return {
17
+ ...react,
18
+ useId: () => ':opaque-mounted-reader:',
19
+ useEffect: () => {},
20
+ useMemo: (factory: () => unknown) => factory(),
21
+ useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, () => {}],
22
+ };
23
+ });
24
+ vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
25
+ JsonBlock: () => null, MarkdownText: () => null, writeClipboard: vi.fn(),
26
+ }));
27
+ vi.mock('../src/client/streaming.js', () => ({
28
+ useStreamingText: (text: string) => ({ text, pending: false, reveal: false, revision: 0 }),
29
+ }));
30
+ vi.mock('../src/client/word-motion.js', () => ({
31
+ MotionMarkdown: () => null, MotionPlainText: () => null,
32
+ }));
33
+ vi.mock('../src/client/McpAppFrame.js', async () => {
34
+ const { createContext } = await import('react');
35
+ return { ComposerFillContext: createContext(undefined), McpAppFrame: () => null };
36
+ });
37
+ vi.mock('../src/client/TurnMetrics.js', () => ({ TurnMetrics: () => null }));
38
+
39
+ type Element = ReactElement<Record<string, unknown>>;
40
+ type ReasoningBlock = { readonly kind: 'reasoning'; readonly text: string };
41
+
42
+ function reasoningRoots(blocks: readonly ReasoningBlock[], streaming = false): Element[] {
43
+ const roots: Element[] = [];
44
+ const renderSlotChain = ((_name: string, _owner: unknown, options: { fallback: ReactNode }) => {
45
+ let element = options.fallback as Element;
46
+ // ReadingReasoning -> [TransformedReasoning ->] ReasoningStream -> div.
47
+ while (typeof element.type === 'function') {
48
+ element = (element.type as (props: Record<string, unknown>) => Element)(element.props);
49
+ }
50
+ assert.equal(element.type, 'div');
51
+ assert.equal(element.props['data-reader-text-kind'], 'reasoning');
52
+ roots.push(element);
53
+ return element;
54
+ }) as BlockRenderProps['renderSlotChain'];
55
+ const renderBlocks = (Blocks as unknown as { type: (props: unknown) => ReactNode }).type;
56
+ renderBlocks({ blocks, streaming, renderSlotChain });
57
+ return roots;
58
+ }
59
+
60
+ const marker = (root: Element) => root.props['data-reader-reasoning-block-key'];
61
+ const shownText = (root: Element) => (root.props.children as Element).props.text;
62
+
63
+ for (const mode of ['no registry', 'body only', 'translated', 'original fallback'] as const) {
64
+ test(`reasoning roots retain their own opaque keys and source text: ${mode}`, () => {
65
+ const registry = mode === 'no registry' ? undefined : createTextTransformRegistry();
66
+ const requests: DisplayTextRequest[] = [];
67
+ if (mode === 'body only') registry!.register('body', { read: () => 'unused body transform' });
68
+ if (mode === 'translated' || mode === 'original fallback') {
69
+ registry!.register('reasoning', {
70
+ read(request) {
71
+ requests.push(request);
72
+ return mode === 'translated' ? 'translated display' : undefined;
73
+ },
74
+ });
75
+ }
76
+ const restore = installTextTransformRegistry(registry);
77
+ const blocks = Object.freeze([
78
+ Object.freeze({ kind: 'reasoning' as const, text: 'identical original' }),
79
+ Object.freeze({ kind: 'reasoning' as const, text: 'identical original' }),
80
+ ]);
81
+ try {
82
+ const roots = reasoningRoots(blocks);
83
+ const keys = roots.map(marker);
84
+ assert.equal(keys.length, 2);
85
+ assert.ok(keys.every(key => typeof key === 'string' && key.length > 0));
86
+ assert.equal(new Set(keys).size, 2, 'identical text must not merge block ownership');
87
+ assert.deepEqual(roots.map(shownText), [
88
+ mode === 'translated' ? 'translated display' : 'identical original',
89
+ mode === 'translated' ? 'translated display' : 'identical original',
90
+ ]);
91
+ assert.equal(requests.length, mode === 'translated' || mode === 'original fallback' ? 2 : 0);
92
+ if (requests.length) {
93
+ assert.deepEqual(requests.map(request => request.blockKey), keys);
94
+ assert.ok(requests.every(request => request.text === 'identical original'));
95
+ }
96
+ assert.deepEqual(blocks.map(block => block.text), ['identical original', 'identical original']);
97
+ const grown = reasoningRoots(blocks.map(block => ({ ...block, text: `${block.text} grows` })), true);
98
+ assert.deepEqual(grown.map(marker), keys, 'streaming growth must preserve each mounted block key');
99
+ } finally {
100
+ restore();
101
+ }
102
+ });
103
+ }
104
+
105
+ test('marker keeps the existing mounted key across source growth, translated display and original override', () => {
106
+ const registry = createTextTransformRegistry();
107
+ const requests: DisplayTextRequest[] = [];
108
+ let original = false;
109
+ registry.register('reasoning', {
110
+ read(request) {
111
+ requests.push(request);
112
+ return original ? undefined : { text: `translated: ${request.text}`, final: !request.streaming };
113
+ },
114
+ });
115
+ const restore = installTextTransformRegistry(registry);
116
+ try {
117
+ const first = reasoningRoots([{ kind: 'reasoning', text: 'hello' }], true)[0];
118
+ const grown = reasoningRoots([{ kind: 'reasoning', text: 'hello world' }], true)[0];
119
+ original = true;
120
+ const settled = reasoningRoots([{ kind: 'reasoning', text: 'hello world' }])[0];
121
+ assert.deepEqual([marker(grown), marker(settled)], [marker(first), marker(first)]);
122
+ assert.deepEqual(requests.map(request => request.blockKey), [marker(first), marker(first), marker(first)]);
123
+ assert.equal(shownText(first), 'translated: hello');
124
+ assert.equal(shownText(grown), 'translated: hello world');
125
+ assert.equal(shownText(settled), 'hello world');
126
+ } finally {
127
+ restore();
128
+ }
129
+ });
@@ -16,19 +16,23 @@ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
16
16
  peerDependencies: Record<string, string>;
17
17
  };
18
18
 
19
- const HARNESS_PEER = '>=0.2.0-rc.1 <0.2.1';
19
+ const HARNESS_PEER = '>=0.2.1-rc.1 <0.2.2';
20
20
 
21
- test('Harness 0.2.0 peer range accepts the rc.1 and stable releases', () => {
21
+ test('Harness 0.2.1 peer range accepts rc.5 and rejects the 0.2.0 series', () => {
22
22
  const peers = Object.entries(pkg.peerDependencies).filter(([name]) => name.startsWith('@deepseek-ai/dsh-'));
23
23
  assert.ok(peers.length >= 12);
24
24
  for (const [name, range] of peers) {
25
25
  assert.equal(range, HARNESS_PEER, name);
26
- assert.equal(semver.satisfies('0.2.0-rc.2', range), true, name);
27
- assert.equal(semver.satisfies('0.2.0', range), true, name);
28
- assert.equal(semver.satisfies('0.2.0-alpha.1', range), false, name);
29
- assert.equal(semver.satisfies('0.2.0-alpha', range), false, name);
26
+ assert.equal(semver.satisfies('0.2.1-rc.5', range), true, name);
27
+ assert.equal(semver.satisfies('0.2.1-rc.1', range), true, name);
28
+ assert.equal(semver.satisfies('0.2.1', range), true, name);
29
+ assert.equal(semver.satisfies('0.2.1-alpha.1', range), false, name);
30
+ assert.equal(semver.satisfies('0.2.0-rc.2', range), false, name);
31
+ assert.equal(semver.satisfies('0.2.0', range), false, name);
30
32
  assert.equal(semver.satisfies('0.1.7-rc.2', range), false, name);
31
33
  }
34
+ const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { engines?: { dsh?: string } };
35
+ assert.equal(manifest.engines?.dsh, HARNESS_PEER);
32
36
  });
33
37
 
34
38
  test('declares dsh.bundle.patch so official add joins the profile layer stack', () => {
package/tsdown.config.ts CHANGED
@@ -30,11 +30,20 @@ const portableOutput: TsdownPlugin = {
30
30
  generateBundle(_options, output) {
31
31
  const client = output['client.js'];
32
32
  if (client?.type !== 'chunk') this.error('client.js was not emitted');
33
+ const root = resolve('.').replaceAll('\\', '/').replace(/\/$/u, '');
34
+ // Rolldown prints `// \0dshx-css-module:<id>`. Older builds used `//#region`.
35
+ // Rewrite either spelling to a package-relative POSIX path before the
36
+ // absolute-path guard, so a checkout path cannot survive into lib/client.js.
33
37
  client.code = client.code.replace(
34
- /^([ \t]*\/\/#region \\0dshx-css-module:).*[\\/]([^/\\\r\n]+\.module\.css\.mjs)(\r?)$/gmu,
35
- '$1$2$3',
38
+ /^([ \t]*\/\/(?:#region )? ?)\\0dshx-css-module:([^\r\n]*?)(\r?)$/gmu,
39
+ (_match, prefix: string, rawPath: string, ending: string) => {
40
+ let cssPath = rawPath.replaceAll('\\', '/');
41
+ const rootPrefix = `${root}/`;
42
+ if (cssPath.startsWith(rootPrefix)) cssPath = cssPath.slice(rootPrefix.length);
43
+ return `${prefix}\\0dshx-css-module:${cssPath}${ending}`;
44
+ },
36
45
  );
37
- if (/^.*\/\/#region \\0dshx-css-module:.*[\\/].*$/mu.test(client.code)) {
46
+ if (/^[ \t]*\/\/(?:#region )? ?\\0dshx-css-module:(?:\/|[A-Za-z]:[\\/])/mu.test(client.code)) {
38
47
  this.error('client.js contains a non-portable CSS module path');
39
48
  }
40
49
  },