Skip to content

Commit 598017f

Browse files
authored
Merge pull request #3360 from superdoc-dev/caio-pizzol/SD-3180-legacy-leaf-facade-entries
feat(types): legacy leaf facade entries (SD-3180)
2 parents d705459 + 9577a28 commit 598017f

12 files changed

Lines changed: 235 additions & 15 deletions

File tree

packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ export class SuperConverter {
55
static extractDocumentGuid(...args: any[]): string | null;
66
[key: string]: any;
77
}
8+
9+
export function hasBodyNumberingReferences(
10+
documentXml: { name?: string; elements?: readonly unknown[] } | null | undefined,
11+
): boolean;

packages/superdoc/scripts/verify-public-facade-emit.cjs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,15 @@
3333
*
3434
* Adding a new facade file:
3535
* - 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`.
36+
* - Wire it into `vite.config.js` (`rollupOptions.input`).
37+
* - If the new entry is intended to ship with both ESM and CJS type
38+
* declarations (i.e. `package.json#exports` will use a `types.import` /
39+
* `types.require` pair), also add it to `cjsDeclarationShims` in
40+
* `scripts/ensure-types.cjs` and set `cjs` on the `FACADE_ENTRIES`
41+
* entry below. If the entry will use a single `types` string instead
42+
* (matching the SD-3180 legacy leaf entries), leave `cjs: null` and
43+
* the parity check is skipped. Phase 4 of SD-3175 owns the contract
44+
* flip and decides per-entry which shape ships.
3845
* - Append a `FACADE_ENTRIES` entry below with the expected symbol set.
3946
* - If the new entry re-exports `EditorCommands`, set
4047
* `runsCommandSignatureProbe: true`.
@@ -97,6 +104,44 @@ const FACADE_ENTRIES = [
97104
runsCommandSignatureProbe: false,
98105
ticket: 'SD-3179',
99106
},
107+
// SD-3180: legacy leaf entries. These match the existing single-types
108+
// pattern of the live `superdoc/converter` / `superdoc/docx-zipper` /
109+
// `superdoc/file-zipper` subpaths, which do not have `.d.cts` shims
110+
// today. `cjs: null` skips the ESM/CJS parity check. Phase 4 decides
111+
// whether to add CJS shims when the contract flips.
112+
{
113+
name: 'legacy/converter',
114+
esm: path.join(PUBLIC_DIST, 'legacy', 'converter.d.ts'),
115+
cjs: null,
116+
// AIDEV-NOTE: `hasBodyNumberingReferences` is in the runtime contract
117+
// of today's `superdoc/converter` (see
118+
// `packages/superdoc/dist/super-editor/converter.es.js`) but missing
119+
// from the existing types entry. The facade types both so Phase 4
120+
// can flip without regressing JS consumers.
121+
expectedNames: ['SuperConverter', 'hasBodyNumberingReferences'],
122+
runsCommandSignatureProbe: false,
123+
ticket: 'SD-3180',
124+
},
125+
{
126+
name: 'legacy/docx-zipper',
127+
esm: path.join(PUBLIC_DIST, 'legacy', 'docx-zipper.d.ts'),
128+
cjs: null,
129+
// AIDEV-NOTE: `default`, not `DocxZipper`. The current public contract
130+
// is `import DocxZipper from 'superdoc/docx-zipper'`. The resolved
131+
// exported name is therefore `default`. Changing to a named export
132+
// would break consumers.
133+
expectedNames: ['default'],
134+
runsCommandSignatureProbe: false,
135+
ticket: 'SD-3180',
136+
},
137+
{
138+
name: 'legacy/file-zipper',
139+
esm: path.join(PUBLIC_DIST, 'legacy', 'file-zipper.d.ts'),
140+
cjs: null,
141+
expectedNames: ['createZip'],
142+
runsCommandSignatureProbe: false,
143+
ticket: 'SD-3180',
144+
},
100145
];
101146

102147
function loadFile(file) {
@@ -108,27 +153,55 @@ function loadFile(file) {
108153
return fs.readFileSync(file, 'utf8');
109154
}
110155

111-
function listExportedNames(file) {
156+
function formatDiagnostic(diagnostic) {
157+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
158+
if (!diagnostic.file || diagnostic.start == null) return message;
159+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
160+
const relative = path.relative(repoRoot, diagnostic.file.fileName);
161+
return `${relative}:${line + 1}:${character + 1} ${message}`;
162+
}
163+
164+
function listExportedNames(entry, file) {
112165
const program = ts.createProgram({
113166
rootNames: [file],
114167
options: {
115168
moduleResolution: ts.ModuleResolutionKind.Bundler,
116169
module: ts.ModuleKind.ESNext,
117170
target: ts.ScriptTarget.ESNext,
118171
noEmit: true,
119-
skipLibCheck: true,
172+
skipLibCheck: false,
120173
},
121174
});
175+
const diagnostics = [
176+
...program.getSyntacticDiagnostics(),
177+
...program.getSemanticDiagnostics(),
178+
...program.getDeclarationDiagnostics(),
179+
];
180+
if (diagnostics.length > 0) {
181+
console.error(`[verify-public-facade-emit] ${entry.name}: facade declaration has TypeScript diagnostics.`);
182+
for (const diagnostic of diagnostics.slice(0, 10)) {
183+
console.error(' - ' + formatDiagnostic(diagnostic));
184+
}
185+
if (diagnostics.length > 10) {
186+
console.error(` ... ${diagnostics.length - 10} more diagnostics`);
187+
}
188+
return { ok: false, names: [] };
189+
}
122190
const checker = program.getTypeChecker();
123191
const src = program.getSourceFile(file);
124192
const symbol = checker.getSymbolAtLocation(src) ?? (src && src.symbol);
125-
if (!symbol) return [];
126-
return [...new Set(checker.getExportsOfModule(symbol).map((s) => s.getName()))].sort();
193+
if (!symbol) return { ok: true, names: [] };
194+
return {
195+
ok: true,
196+
names: [...new Set(checker.getExportsOfModule(symbol).map((s) => s.getName()))].sort(),
197+
};
127198
}
128199

129200
function checkSymbolSet(entry) {
130201
const expected = [...entry.expectedNames].sort();
131-
const actual = listExportedNames(entry.esm);
202+
const result = listExportedNames(entry, entry.esm);
203+
if (!result.ok) return { ok: false, actual: result.names };
204+
const actual = result.names;
132205
if (JSON.stringify(actual) === JSON.stringify(expected)) {
133206
return { ok: true, actual };
134207
}
@@ -141,7 +214,9 @@ function checkSymbolSet(entry) {
141214
}
142215

143216
function checkEsmCjsParity(entry, esmNames) {
144-
const cjsNames = listExportedNames(entry.cjs);
217+
const result = listExportedNames(entry, entry.cjs);
218+
if (!result.ok) return false;
219+
const cjsNames = result.names;
145220
if (JSON.stringify(esmNames) === JSON.stringify(cjsNames)) return true;
146221
const importOnly = esmNames.filter((n) => !cjsNames.includes(n));
147222
const requireOnly = cjsNames.filter((n) => !esmNames.includes(n));
@@ -218,7 +293,9 @@ const LEAK_PATTERNS = [
218293

219294
function checkLeaks(entry) {
220295
let ok = true;
221-
for (const file of [entry.esm, entry.cjs]) {
296+
const files = [entry.esm];
297+
if (entry.cjs) files.push(entry.cjs);
298+
for (const file of files) {
222299
const code = stripComments(loadFile(file));
223300
for (const pattern of LEAK_PATTERNS) {
224301
const matches = code.match(pattern.re);
@@ -239,7 +316,10 @@ for (const entry of FACADE_ENTRIES) {
239316
const symbolResult = checkSymbolSet(entry);
240317
if (!symbolResult.ok) failed = true;
241318

242-
if (!checkEsmCjsParity(entry, symbolResult.actual)) failed = true;
319+
// Entries with `cjs: null` (e.g. SD-3180 legacy leaf entries that match
320+
// the existing single-types pattern) skip the parity check until Phase 4
321+
// decides whether to add proper CJS shims.
322+
if (entry.cjs && !checkEsmCjsParity(entry, symbolResult.actual)) failed = true;
243323

244324
if (entry.runsCommandSignatureProbe && !checkCommandSignatureProbe(entry)) {
245325
failed = true;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { SuperConverter, hasBodyNumberingReferences } from './converter.js';
3+
4+
/**
5+
* Smoke test for the legacy public facade converter entry (SD-3180).
6+
* Declaration-side validation (symbol set, leak grep) lives in
7+
* `packages/superdoc/scripts/verify-public-facade-emit.cjs`.
8+
*/
9+
describe('public facade (legacy/converter)', () => {
10+
it('re-exports SuperConverter as a constructor', () => {
11+
expect(typeof SuperConverter).toBe('function');
12+
});
13+
14+
it('re-exports hasBodyNumberingReferences as a function', () => {
15+
expect(typeof hasBodyNumberingReferences).toBe('function');
16+
});
17+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* SuperDoc public facade: legacy converter entry.
3+
*
4+
* SD-3180 under SD-3178 (Phase 3 of SD-3175). Mirrors the existing
5+
* `superdoc/converter` subpath under the path-as-contract structure.
6+
*
7+
* Classification: **legacy public compatibility surface** per
8+
* `docs/architecture/package-boundaries.md` Decision 4. New code should
9+
* import `SuperConverter` from `superdoc` directly.
10+
*
11+
* AIDEV-NOTE: The runtime contract for `superdoc/converter` today exports
12+
* both `SuperConverter` and `hasBodyNumberingReferences` (see
13+
* `packages/superdoc/dist/super-editor/converter.es.js`). The existing
14+
* types entry only declares `SuperConverter`, so the SD-3176 typed
15+
* snapshot shows 1 name while the runtime contract has 2. This facade
16+
* types both so Phase 4 can flip `package.json#exports` without
17+
* regressing JS consumers doing
18+
* `import { hasBodyNumberingReferences } from 'superdoc/converter'`.
19+
* Adding or removing an export here updates the `expectedNames` for
20+
* the `legacy/converter` entry in `FACADE_ENTRIES` inside
21+
* `packages/superdoc/scripts/verify-public-facade-emit.cjs` in the
22+
* same PR.
23+
*/
24+
export { SuperConverter, hasBodyNumberingReferences } from '@superdoc/super-editor/converter';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, it, expect } from 'vitest';
2+
import DocxZipper from './docx-zipper.js';
3+
4+
/**
5+
* Smoke test for the legacy public facade docx-zipper entry (SD-3180).
6+
* This one specifically validates the default-import contract:
7+
* `import DocxZipper from 'superdoc/docx-zipper'` is the existing
8+
* public contract and must keep working through the facade.
9+
*/
10+
describe('public facade (legacy/docx-zipper)', () => {
11+
it('re-exports DocxZipper as the default export (constructor)', () => {
12+
expect(typeof DocxZipper).toBe('function');
13+
expect(DocxZipper.name).toBe('DocxZipper');
14+
});
15+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* SuperDoc public facade: legacy docx-zipper entry.
3+
*
4+
* SD-3180 under SD-3178 (Phase 3 of SD-3175). Mirrors the existing
5+
* `superdoc/docx-zipper` subpath under the path-as-contract structure.
6+
*
7+
* AIDEV-NOTE: This entry is a **default export**, not a named export.
8+
* The current public contract is `import DocxZipper from 'superdoc/docx-zipper'`,
9+
* which means the resolved declaration's exported name must be `default`.
10+
* Importing the default from the narrow `@superdoc/super-editor/docx-zipper`
11+
* subpath and re-exporting it as the default preserves that contract and
12+
* keeps the emitted bundle narrow (no broad super-editor root graph).
13+
*
14+
* Classification: **legacy public compatibility surface** per
15+
* `docs/architecture/package-boundaries.md` Decision 4. New code should
16+
* import `DocxZipper` from `superdoc` directly:
17+
*
18+
* import { DocxZipper } from 'superdoc';
19+
*
20+
* AIDEV-NOTE: Single-export facade. Update `expectedNames` for the
21+
* `legacy/docx-zipper` entry in `FACADE_ENTRIES` inside
22+
* `packages/superdoc/scripts/verify-public-facade-emit.cjs` in the
23+
* same PR if the surface changes.
24+
*/
25+
import DocxZipper from '@superdoc/super-editor/docx-zipper';
26+
export default DocxZipper;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { createZip } from './file-zipper.js';
3+
4+
describe('public facade (legacy/file-zipper)', () => {
5+
it('re-exports createZip as a function', () => {
6+
expect(typeof createZip).toBe('function');
7+
});
8+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* SuperDoc public facade: legacy file-zipper entry.
3+
*
4+
* SD-3180 under SD-3178 (Phase 3 of SD-3175). Mirrors the existing
5+
* `superdoc/file-zipper` subpath under the path-as-contract structure.
6+
*
7+
* Classification: **legacy public compatibility surface** per
8+
* `docs/architecture/package-boundaries.md` Decision 4. New code should
9+
* import `createZip` from `superdoc` directly.
10+
*
11+
* AIDEV-NOTE: Single-export facade. Growing this list ships a new public
12+
* symbol through a legacy compat path. Update `expectedNames` for the
13+
* `legacy/file-zipper` entry in `FACADE_ENTRIES` inside
14+
* `packages/superdoc/scripts/verify-public-facade-emit.cjs` in the
15+
* same PR.
16+
*/
17+
export { createZip } from '@superdoc/super-editor/file-zipper';

packages/superdoc/vite.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ export default defineConfig(({ mode, command }) => {
252252
// custom UI integrations should use the `superdoc/ui` /
253253
// `superdoc/ui/react` entries instead.
254254
'public/legacy/headless-toolbar': 'src/public/legacy/headless-toolbar.ts',
255+
// SD-3180: legacy leaf facade entries mirroring the existing
256+
// single-export legacy subpaths. Same classification as
257+
// headless-toolbar above.
258+
'public/legacy/converter': 'src/public/legacy/converter.ts',
259+
'public/legacy/docx-zipper': 'src/public/legacy/docx-zipper.ts',
260+
'public/legacy/file-zipper': 'src/public/legacy/file-zipper.ts',
255261
},
256262
external: [
257263
'yjs',

tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,36 @@ function snapshotName(subpath) {
9999
return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt';
100100
}
101101

102-
function listExportedNames(entryFile) {
102+
function formatDiagnostic(diagnostic) {
103+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
104+
if (!diagnostic.file || diagnostic.start == null) return message;
105+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
106+
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`;
107+
}
108+
109+
function listExportedNames(subpath, entryFile) {
103110
const program = ts.createProgram({
104111
rootNames: [entryFile],
105112
options: {
106113
moduleResolution: ts.ModuleResolutionKind.Bundler,
107114
module: ts.ModuleKind.ESNext,
108115
target: ts.ScriptTarget.ESNext,
109116
noEmit: true,
110-
skipLibCheck: true,
117+
skipLibCheck: false,
111118
allowJs: false,
112119
declaration: false,
113120
},
114121
});
122+
const diagnostics = [
123+
...program.getSyntacticDiagnostics(),
124+
...program.getSemanticDiagnostics(),
125+
...program.getDeclarationDiagnostics(),
126+
];
127+
if (diagnostics.length > 0) {
128+
const details = diagnostics.slice(0, 10).map((diagnostic) => ` - ${formatDiagnostic(diagnostic)}`).join('\n');
129+
const suffix = diagnostics.length > 10 ? `\n ... ${diagnostics.length - 10} more diagnostics` : '';
130+
throw new Error(`${subpath} declaration has TypeScript diagnostics:\n${details}${suffix}`);
131+
}
115132
const checker = program.getTypeChecker();
116133
const source = program.getSourceFile(entryFile);
117134
if (!source) throw new Error('Cannot load source: ' + entryFile);
@@ -139,7 +156,7 @@ for (const subpath of SUBPATHS) {
139156

140157
let names;
141158
try {
142-
names = listExportedNames(importFile);
159+
names = listExportedNames(subpath, importFile);
143160
} catch (err) {
144161
console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`);
145162
failed = true;
@@ -160,7 +177,7 @@ for (const subpath of SUBPATHS) {
160177
}
161178
let cjsNames;
162179
try {
163-
cjsNames = listExportedNames(requireFile);
180+
cjsNames = listExportedNames(subpath, requireFile);
164181
} catch (err) {
165182
console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`);
166183
failed = true;

0 commit comments

Comments
 (0)