Skip to content

Commit d705459

Browse files
authored
Merge pull request #3359 from superdoc-dev/caio-pizzol/SD-3179-public-facade-headless-toolbar
feat(types): legacy headless-toolbar public facade entry (SD-3179)
2 parents f84c326 + 3a7c159 commit d705459

11 files changed

Lines changed: 282 additions & 84 deletions

docs/architecture/package-boundaries.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ The `superdoc` package currently exposes the following entries via `package.json
109109
| `./super-editor` | Yes | Legacy public compatibility surface | `imports-sub-export.ts` | Was effectively public when no other headless path existed. `Editor`, `PresentationEditor`, `getStarterExtensions`, `Extensions`, `SuperToolbar`, `SuperConverter`, `DocxZipper` and most of the surface are now exported from `superdoc` itself. Kept exported, not advertised, migration target is `superdoc`. See Decision 1. |
110110
| `./ui` | Yes | Public subpath | `imports-ui.ts` | Stays |
111111
| `./ui/react` | Yes | Public subpath | `imports-ui-react.ts` | Stays |
112-
| `./headless-toolbar` | Yes | Public subpath | `imports-headless-toolbar.ts` | Stays |
113-
| `./headless-toolbar/react` | Yes | Public subpath | `imports-headless-toolbar-react.ts` | Stays |
114-
| `./headless-toolbar/vue` | Yes | Public subpath | `imports-headless-toolbar-vue.ts` | Stays |
112+
| `./headless-toolbar` | Yes | Legacy public compatibility surface | `imports-headless-toolbar.ts` | Kept exported, not advertised. New custom UI integrations should use `superdoc/ui`. See Decision 4. |
113+
| `./headless-toolbar/react` | Yes | Legacy public compatibility surface | `imports-headless-toolbar-react.ts` | Framework helper for the legacy headless toolbar. Migration target is `superdoc/ui/react`. See Decision 4. |
114+
| `./headless-toolbar/vue` | Yes | Legacy public compatibility surface | `imports-headless-toolbar-vue.ts` | Framework helper for the legacy headless toolbar. New work that needs a Vue UI controller should track that as a separate decision; the legacy entry is kept compiling. See Decision 4. |
115115
| `./converter` | Yes (SD-2953) | Legacy public compatibility surface | `imports-converter.ts` | DOCX conversion is also reachable through `Editor.open` / `Editor.loadXmlData` / `SuperConverter` exported from `superdoc`. Kept exported, not advertised, migration target is `superdoc`. Types added in SD-2953 to satisfy strict-mode consumers. |
116116
| `./docx-zipper` | Yes (SD-2953) | Legacy public compatibility surface | `imports-docx-zipper.ts` | `DocxZipper` is exported from `superdoc`. Kept exported, not advertised, migration target is `superdoc`. Types added in SD-2953. |
117117
| `./file-zipper` | Yes (SD-2953) | Legacy public compatibility surface | `imports-file-zipper.ts` | `createZip` is exported from `superdoc`. Kept exported, not advertised, migration target is `superdoc`. Types added in SD-2953. |
@@ -182,7 +182,20 @@ The relocation pattern is what `superdoc` currently uses for several internal-bu
182182

183183
**Context.** `./converter`, `./docx-zipper`, `./file-zipper` are exported subpaths whose functionality (DOCX conversion, zipping) is also reachable through `superdoc`'s main entry: `Editor.open`, `Editor.loadXmlData`, `SuperConverter`, `DocxZipper`, `createZip` are all exported from `superdoc`.
184184

185-
**Decision.** All three subpaths are classified as **legacy public compatibility surface**. Migration target is `superdoc` itself (the symbols already exist there). We keep them exported, stop advertising them, and point new use at `superdoc`. SD-2953 added `types` fields and matrix fixtures so strict-mode consumers no longer hit TS7016; the export-coverage audit (`check-export-coverage.cjs`) now enforces that every `package.json` exports entry carries types, an asset classification, or a documented runtime-only allowlist entry.
185+
`./headless-toolbar` is the same shape with a different migration target: the next-generation custom UI story is `superdoc/ui` and `superdoc/ui/react` (the typed UI controller). Existing consumers of the headless-toolbar surface keep compiling; new integrations should use the UI controller entries.
186+
187+
**Decision.** `./converter`, `./docx-zipper`, `./file-zipper`, and the `./headless-toolbar` family (`./headless-toolbar`, `./headless-toolbar/react`, `./headless-toolbar/vue`) are classified as **legacy public compatibility surface**.
188+
189+
| Subpath | Migration target |
190+
| --- | --- |
191+
| `./converter` | `SuperConverter` from `superdoc` |
192+
| `./docx-zipper` | `DocxZipper` from `superdoc` |
193+
| `./file-zipper` | `createZip` from `superdoc` |
194+
| `./headless-toolbar` | `superdoc/ui` |
195+
| `./headless-toolbar/react` | `superdoc/ui/react` |
196+
| `./headless-toolbar/vue` | Track a Vue UI controller as a separate decision; the legacy entry is kept compiling. |
197+
198+
We keep them exported, stop advertising them, and point new use at the migration target. SD-2953 added `types` fields and matrix fixtures so strict-mode consumers no longer hit TS7016; the export-coverage audit (`check-export-coverage.cjs`) now enforces that every `package.json` exports entry carries types, an asset classification, or a documented runtime-only allowlist entry. SD-3179 lands the source-side facade for `./headless-toolbar` under `packages/superdoc/src/public/legacy/` and extends SD-3176's no-growth snapshot list to cover `./headless-toolbar`, `./headless-toolbar/react`, and `./headless-toolbar/vue` so these subpaths cannot expand silently.
186199

187200
## Deliverables
188201

packages/superdoc/scripts/ensure-types.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ const cjsDeclarationShims = [
140140
source: path.join(distRoot, 'superdoc/src/public/index.d.ts'),
141141
target: './index.js',
142142
},
143+
// SD-3179: legacy headless-toolbar facade entry.
144+
{
145+
file: path.join(distRoot, 'superdoc/src/public/legacy/headless-toolbar.d.cts'),
146+
source: path.join(distRoot, 'superdoc/src/public/legacy/headless-toolbar.d.ts'),
147+
target: './headless-toolbar.js',
148+
},
143149
];
144150

145151
function isValidIdentifier(name) {
Lines changed: 127 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
#!/usr/bin/env node
22
/**
3-
* SD-3178 (Phase 3 of SD-3175): verify the explicit public facade emits a
4-
* declaration tree that is safe to ship.
3+
* SD-3178 (Phase 3 of SD-3175): verify the explicit public facade entries
4+
* emit declaration trees that are safe to ship.
55
*
6-
* Runs as a postbuild step under `packages/superdoc/`. Loads the emitted
7-
* `dist/superdoc/src/public/index.d.ts` and `index.d.cts` with the
8-
* TypeScript compiler API and asserts:
6+
* Runs as a postbuild step under `packages/superdoc/`. For each entry in
7+
* the `FACADE_ENTRIES` config below, loads the emitted `.d.ts` and `.d.cts`
8+
* with the TypeScript compiler API and asserts:
99
*
1010
* 1. The expected symbol set is exported from each declaration file.
1111
* 2. The ESM and CJS declarations agree on the exported names.
12-
* 3. The command signature surface survives the facade emit. This is
13-
* the SD-2965 regression vector: specific command signatures getting
14-
* dropped or failing to flow through the facade. `EditorCommands` is
15-
* `CoreCommands & ExtensionCommands & AllCommandSignatures & Record<string, AnyCommand>`,
16-
* so the trailing `Record<string, AnyCommand>` makes any indexer
17-
* lookup resolve even when the specific signatures are missing.
18-
* The probe asserts the RETURN TYPE of two commands (`setBold`,
12+
* 3. (Root entry only) The command signature surface survives the
13+
* facade emit. This is the SD-2965 regression vector: specific
14+
* command signatures getting dropped or failing to flow through the
15+
* facade. `EditorCommands` is `CoreCommands & ExtensionCommands &
16+
* AllCommandSignatures & Record<string, AnyCommand>`, so the
17+
* trailing `Record<string, AnyCommand>` makes any indexer lookup
18+
* resolve even when the specific signatures are missing. The probe
19+
* asserts the RETURN TYPE of two commands (`setBold`,
1920
* `insertComment`) is `boolean`, not the `AnyCommand` fallback's
2021
* `unknown`. Two commands from two signature sources (formatting +
2122
* comments) catch partial drops a single-command probe would miss.
@@ -30,6 +31,14 @@
3031
* not see the workspace specifier. Later SD-3178 follow-ups reduce
3132
* how much the facade depends on that broader declaration graph.
3233
*
34+
* Adding a new facade file:
35+
* - Create `packages/superdoc/src/public/<name>.ts` with named exports.
36+
* - Wire it into `vite.config.js` (`rollupOptions.input`) and the CJS
37+
* shim list in `scripts/ensure-types.cjs`.
38+
* - Append a `FACADE_ENTRIES` entry below with the expected symbol set.
39+
* - If the new entry re-exports `EditorCommands`, set
40+
* `runsCommandSignatureProbe: true`.
41+
*
3342
* Exits non-zero on any failure. Designed to fail loud and early.
3443
*/
3544
'use strict';
@@ -39,10 +48,7 @@ const path = require('node:path');
3948

4049
const repoRoot = path.resolve(__dirname, '..', '..', '..');
4150
const distRoot = path.resolve(__dirname, '..', 'dist');
42-
const FACADE_ESM = path.join(distRoot, 'superdoc', 'src', 'public', 'index.d.ts');
43-
const FACADE_CJS = path.join(distRoot, 'superdoc', 'src', 'public', 'index.d.cts');
44-
45-
const EXPECTED_NAMES = ['Config', 'Editor', 'EditorCommands', 'SuperDoc'].sort();
51+
const PUBLIC_DIST = path.join(distRoot, 'superdoc', 'src', 'public');
4652

4753
let ts;
4854
try {
@@ -52,6 +58,47 @@ try {
5258
process.exit(1);
5359
}
5460

61+
// AIDEV-NOTE: Adding or removing an export from a facade file in
62+
// `packages/superdoc/src/public/**` must update the matching
63+
// `expectedNames` list below in the same PR. Skipping that step fails
64+
// this gate. Link the PR to SD-3175 (path-as-contract umbrella) for
65+
// reviewer sign-off when growth is intentional.
66+
const FACADE_ENTRIES = [
67+
{
68+
name: 'root (./index)',
69+
esm: path.join(PUBLIC_DIST, 'index.d.ts'),
70+
cjs: path.join(PUBLIC_DIST, 'index.d.cts'),
71+
expectedNames: ['Config', 'Editor', 'EditorCommands', 'SuperDoc'],
72+
runsCommandSignatureProbe: true,
73+
ticket: 'SD-3178',
74+
},
75+
{
76+
name: 'legacy/headless-toolbar',
77+
esm: path.join(PUBLIC_DIST, 'legacy', 'headless-toolbar.d.ts'),
78+
cjs: path.join(PUBLIC_DIST, 'legacy', 'headless-toolbar.d.cts'),
79+
expectedNames: [
80+
'CreateHeadlessToolbarOptions',
81+
'HeadlessToolbarController',
82+
'HeadlessToolbarSuperdocHost',
83+
'HeadlessToolbarSurface',
84+
'PublicToolbarItemId',
85+
'ToolbarCommandState',
86+
'ToolbarCommandStates',
87+
'ToolbarContext',
88+
'ToolbarExecuteFn',
89+
'ToolbarPayloadMap',
90+
'ToolbarSnapshot',
91+
'ToolbarTarget',
92+
'ToolbarValueMap',
93+
'createHeadlessToolbar',
94+
'headlessToolbarConstants',
95+
'headlessToolbarHelpers',
96+
],
97+
runsCommandSignatureProbe: false,
98+
ticket: 'SD-3179',
99+
},
100+
];
101+
55102
function loadFile(file) {
56103
if (!fs.existsSync(file)) {
57104
console.error(`[verify-public-facade-emit] missing facade declaration: ${file}`);
@@ -75,48 +122,39 @@ function listExportedNames(file) {
75122
const checker = program.getTypeChecker();
76123
const src = program.getSourceFile(file);
77124
const symbol = checker.getSymbolAtLocation(src) ?? (src && src.symbol);
78-
if (!symbol) {
79-
return { names: [], program, checker };
80-
}
81-
return {
82-
names: [...new Set(checker.getExportsOfModule(symbol).map((s) => s.getName()))].sort(),
83-
program,
84-
checker,
85-
};
125+
if (!symbol) return [];
126+
return [...new Set(checker.getExportsOfModule(symbol).map((s) => s.getName()))].sort();
86127
}
87128

88-
let failed = false;
89-
90-
// (1) Symbol set.
91-
const esm = listExportedNames(FACADE_ESM);
92-
if (JSON.stringify(esm.names) !== JSON.stringify(EXPECTED_NAMES)) {
93-
console.error(`[verify-public-facade-emit] ESM facade exports drifted.`);
94-
console.error(' expected: ' + EXPECTED_NAMES.join(', '));
95-
console.error(' actual: ' + esm.names.join(', '));
96-
console.error(' If this addition is intentional, update EXPECTED_NAMES in this script and link');
97-
console.error(' the PR to SD-3175 (path-as-contract umbrella) for reviewer sign-off.');
98-
failed = true;
129+
function checkSymbolSet(entry) {
130+
const expected = [...entry.expectedNames].sort();
131+
const actual = listExportedNames(entry.esm);
132+
if (JSON.stringify(actual) === JSON.stringify(expected)) {
133+
return { ok: true, actual };
134+
}
135+
console.error(`[verify-public-facade-emit] ${entry.name}: facade exports drifted.`);
136+
console.error(' expected: ' + expected.join(', '));
137+
console.error(' actual: ' + actual.join(', '));
138+
console.error(` If this addition is intentional, update FACADE_ENTRIES["${entry.name}"].expectedNames in this script and link`);
139+
console.error(` the PR to ${entry.ticket} / SD-3175 (path-as-contract umbrella) for reviewer sign-off.`);
140+
return { ok: false, actual };
99141
}
100142

101-
// (2) ESM/CJS parity.
102-
const cjs = listExportedNames(FACADE_CJS);
103-
if (JSON.stringify(esm.names) !== JSON.stringify(cjs.names)) {
104-
const importOnly = esm.names.filter((n) => !cjs.names.includes(n));
105-
const requireOnly = cjs.names.filter((n) => !esm.names.includes(n));
106-
console.error('[verify-public-facade-emit] ESM/CJS facade declarations disagree on exports.');
143+
function checkEsmCjsParity(entry, esmNames) {
144+
const cjsNames = listExportedNames(entry.cjs);
145+
if (JSON.stringify(esmNames) === JSON.stringify(cjsNames)) return true;
146+
const importOnly = esmNames.filter((n) => !cjsNames.includes(n));
147+
const requireOnly = cjsNames.filter((n) => !esmNames.includes(n));
148+
console.error(`[verify-public-facade-emit] ${entry.name}: ESM/CJS facade declarations disagree on exports.`);
107149
if (importOnly.length) console.error(' ESM-only: ' + importOnly.join(', '));
108150
if (requireOnly.length) console.error(' CJS-only: ' + requireOnly.join(', '));
109151
console.error(' Fix the CJS shim generator (packages/superdoc/scripts/ensure-types.cjs).');
110-
failed = true;
152+
return false;
111153
}
112154

113-
// (3) Command signature survival: assert two commands return `boolean`,
114-
// not the `AnyCommand` fallback. See header for why a bare resolution
115-
// check is not enough (the `Record<string, AnyCommand>` intersection
116-
// always satisfies the indexer).
117-
{
155+
function checkCommandSignatureProbe(entry) {
118156
const probe = `
119-
import type { EditorCommands } from ${JSON.stringify(FACADE_ESM)};
157+
import type { EditorCommands } from ${JSON.stringify(entry.esm)};
120158
type ReturnsBoolean<F> = F extends (...args: any[]) => boolean ? true : false;
121159
// Direct assignment of literal \`true\` to the conditional result. If the
122160
// signature is missing and the indexer falls back to AnyCommand, the
@@ -150,25 +188,22 @@ if (JSON.stringify(esm.names) !== JSON.stringify(cjs.names)) {
150188
...program.getSemanticDiagnostics(),
151189
...program.getDeclarationDiagnostics(),
152190
];
153-
if (diagnostics.length > 0) {
154-
console.error('[verify-public-facade-emit] command signature probe failed.');
155-
console.error(' A command (setBold or insertComment) does not return `boolean` through the facade.');
156-
console.error(' This is the SD-2965 regression vector: specific command signatures were dropped or failed to flow through the facade, and EditorCommands fell back to the `AnyCommand` indexer.');
157-
for (const d of diagnostics) {
158-
const msg = typeof d.messageText === 'string'
159-
? d.messageText
160-
: ts.flattenDiagnosticMessageText(d.messageText, '\n');
161-
console.error(' - ' + msg);
162-
}
163-
failed = true;
191+
if (diagnostics.length === 0) return true;
192+
console.error(`[verify-public-facade-emit] ${entry.name}: command signature probe failed.`);
193+
console.error(' A command (setBold or insertComment) does not return `boolean` through the facade.');
194+
console.error(' This is the SD-2965 regression vector: specific command signatures were dropped or failed to flow through the facade, and EditorCommands fell back to the `AnyCommand` indexer.');
195+
for (const d of diagnostics) {
196+
const msg = typeof d.messageText === 'string'
197+
? d.messageText
198+
: ts.flattenDiagnosticMessageText(d.messageText, '\n');
199+
console.error(' - ' + msg);
164200
}
201+
return false;
165202
} finally {
166203
try { fs.unlinkSync(probePath); } catch (_) {}
167204
}
168205
}
169206

170-
// (4) No internal leaks in emitted code (strip JSDoc/line comments first so
171-
// that comments referencing `@superdoc/super-editor` in prose are not flagged).
172207
function stripComments(source) {
173208
return source
174209
.replace(/\/\*[\s\S]*?\*\//g, '')
@@ -181,16 +216,38 @@ const LEAK_PATTERNS = [
181216
{ name: 'absolute source path', re: /['"][\/\\][^'"\n]*\/(packages|node_modules)\//g },
182217
];
183218

184-
for (const file of [FACADE_ESM, FACADE_CJS]) {
185-
const code = stripComments(loadFile(file));
186-
for (const pattern of LEAK_PATTERNS) {
187-
const matches = code.match(pattern.re);
188-
if (matches && matches.length > 0) {
189-
console.error(`[verify-public-facade-emit] leak in ${path.relative(repoRoot, file)}:`);
190-
console.error(` ${pattern.name}: ${matches.slice(0, 5).join(', ')}`);
191-
failed = true;
219+
function checkLeaks(entry) {
220+
let ok = true;
221+
for (const file of [entry.esm, entry.cjs]) {
222+
const code = stripComments(loadFile(file));
223+
for (const pattern of LEAK_PATTERNS) {
224+
const matches = code.match(pattern.re);
225+
if (matches && matches.length > 0) {
226+
console.error(`[verify-public-facade-emit] ${entry.name}: leak in ${path.relative(repoRoot, file)}:`);
227+
console.error(` ${pattern.name}: ${matches.slice(0, 5).join(', ')}`);
228+
ok = false;
229+
}
192230
}
193231
}
232+
return ok;
233+
}
234+
235+
let failed = false;
236+
const summaryLines = [];
237+
238+
for (const entry of FACADE_ENTRIES) {
239+
const symbolResult = checkSymbolSet(entry);
240+
if (!symbolResult.ok) failed = true;
241+
242+
if (!checkEsmCjsParity(entry, symbolResult.actual)) failed = true;
243+
244+
if (entry.runsCommandSignatureProbe && !checkCommandSignatureProbe(entry)) {
245+
failed = true;
246+
}
247+
248+
if (!checkLeaks(entry)) failed = true;
249+
250+
summaryLines.push(`${entry.name}: ${symbolResult.actual.length} exports`);
194251
}
195252

196253
if (failed) {
@@ -199,4 +256,4 @@ if (failed) {
199256
process.exit(1);
200257
}
201258

202-
console.log(`[verify-public-facade-emit] OK. Facade emits cleanly: ${esm.names.length} exports, ESM/CJS in parity, command signatures survive.`);
259+
console.log(`[verify-public-facade-emit] OK. Facade emits cleanly across ${FACADE_ENTRIES.length} entries (${summaryLines.join('; ')}).`);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
createHeadlessToolbar,
4+
headlessToolbarConstants,
5+
headlessToolbarHelpers,
6+
} from './headless-toolbar.js';
7+
8+
/**
9+
* Smoke test for the legacy public facade headless-toolbar entry (SD-3179).
10+
* The three runtime re-exports need coverage so the facade file does not
11+
* show 0% on the unit-test coverage report. Declaration-side validation
12+
* (symbol set, ESM/CJS parity, leak grep) lives in
13+
* `packages/superdoc/scripts/verify-public-facade-emit.cjs`.
14+
*/
15+
describe('public facade (legacy/headless-toolbar)', () => {
16+
it('re-exports createHeadlessToolbar as a function', () => {
17+
expect(typeof createHeadlessToolbar).toBe('function');
18+
});
19+
20+
it('re-exports headlessToolbarConstants as an object', () => {
21+
expect(headlessToolbarConstants).toBeDefined();
22+
expect(typeof headlessToolbarConstants).toBe('object');
23+
});
24+
25+
it('re-exports headlessToolbarHelpers as an object', () => {
26+
expect(headlessToolbarHelpers).toBeDefined();
27+
expect(typeof headlessToolbarHelpers).toBe('object');
28+
});
29+
});

0 commit comments

Comments
 (0)