diff --git a/packages/super-editor/src/editors/v1/core/CommandService.js b/packages/super-editor/src/editors/v1/core/CommandService.js index fd5a1faf80..a7be3fa113 100644 --- a/packages/super-editor/src/editors/v1/core/CommandService.js +++ b/packages/super-editor/src/editors/v1/core/CommandService.js @@ -209,7 +209,17 @@ export class CommandService { get commands() { return Object.fromEntries( Object.entries(rawCommands).map(([name, command]) => { - return [name, (...args) => command(...args)(props)]; + // SD-3240: SurfaceCommandCallable types `props` as the public + // `CommandProps` shape, while the locally-built props here is + // a structurally-compatible JS object (the JS file's `state` + // helper returns `any` until typed). Cast at the boundary. + return [ + name, + (...args) => + command(...args)( + /** @type {import('./types/ChainedCommands.js').CommandProps} */ (/** @type {unknown} */ (props)), + ), + ]; }), ); }, diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 0d056a3183..5d0c57ef11 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -18,6 +18,7 @@ import { ExtensionService } from './ExtensionService.js'; import { CommandService } from './CommandService.js'; import { Attribute } from './Attribute.js'; import { SuperConverter } from '@core/super-converter/SuperConverter.js'; +import type { EditorConverterSurface, EditorExtensionServiceSurface } from './types/EditorPublicSurfaces.js'; import { Commands, Editable, @@ -253,10 +254,8 @@ export class Editor extends EventEmitter { */ #commandService!: CommandService; - /** - * Service for managing extensions - */ - extensionService!: ExtensionService; + /** Extension service. See `EditorExtensionServiceSurface`. SD-3240. */ + extensionService!: EditorExtensionServiceSurface; /** * Storage for extension data @@ -348,7 +347,8 @@ export class Editor extends EventEmitter { /** * The document converter instance */ - converter!: SuperConverter; + /** Document converter handle. See `EditorConverterSurface`. SD-3240. */ + converter!: EditorConverterSurface; /** * Toolbar instance (if attached) @@ -648,7 +648,7 @@ export class Editor extends EventEmitter { if (!this.#telemetry || this.#documentOpenTracked) return; try { - const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; + const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() ?? null; this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); this.#documentOpenTracked = true; } catch { @@ -1108,7 +1108,14 @@ export class Editor extends EventEmitter { #initProtectionState(): void { const protStorage = getProtectionStorage(this); if (!protStorage) return; - const settingsRoot = this.converter ? readSettingsRoot(this.converter) : null; + // SD-3240: readSettingsRoot accepts a narrow `ConverterWithDocumentSettings` + // shape that overlaps with EditorConverterSurface but uses a + // different `pageStyles` typing (alternateHeaders flag). Cast to + // the local narrow interface. Both shapes are honest no-`any` + // contracts on the same runtime instance. + const settingsRoot = this.converter + ? readSettingsRoot(this.converter as unknown as Parameters[0]) + : null; protStorage.state = parseProtectionState(settingsRoot); protStorage.initialized = true; } @@ -2115,7 +2122,15 @@ export class Editor extends EventEmitter { const isolatedExternalExtensions = externalExtensions.map((extension) => cloneExtensionInstance(extension)); - this.extensionService = ExtensionService.create(allExtensions, isolatedExternalExtensions, this); + // SD-3240: ExtensionService.d.ts uses a `[key: string]: any` catchall + // for internal-implementation members. The runtime instance has the + // surface members; the cast bridges the structural gap without + // reintroducing `any` on the public field type. + this.extensionService = ExtensionService.create( + allExtensions, + isolatedExternalExtensions, + this, + ) as unknown as EditorExtensionServiceSurface; } /** @@ -2131,8 +2146,12 @@ export class Editor extends EventEmitter { * Create the document converter as this.converter. */ #createConverter(): void { + // SD-3240: SuperConverter.d.ts uses a `[key: string]: any` catchall + // for internal-implementation members. The runtime instance has the + // surface members; the cast bridges the structural gap without + // reintroducing `any` on the public field type. if (this.options.converter) { - this.converter = this.options.converter as SuperConverter; + this.converter = this.options.converter as unknown as EditorConverterSurface; } else { this.converter = new SuperConverter({ docx: this.options.content, @@ -2145,7 +2164,7 @@ export class Editor extends EventEmitter { mockDocument: this.options.mockDocument ?? null, isNewFile: this.options.isNewFile ?? false, trackedChangesOptions: this.options.trackedChanges ?? null, - }); + }) as unknown as EditorConverterSurface; } } @@ -2367,7 +2386,10 @@ export class Editor extends EventEmitter { const suppressedNames = new Set( (this.extensionService?.extensions || []) - .filter((ext: { config?: { excludeFromSummaryJSON?: boolean } }) => { + .filter((ext) => { + // SD-3240: extension entries are typed but `excludeFromSummaryJSON` + // is a runtime opt-in on the config record (Options/Storage generics + // hide it). Cast at the read site to access the optional flag. const config = (ext as { config?: { excludeFromSummaryJSON?: boolean } })?.config; const suppressFlag = config?.excludeFromSummaryJSON; return Boolean(suppressFlag); @@ -3233,7 +3255,13 @@ export class Editor extends EventEmitter { this.#validateDocumentExport(); - if (exportXmlOnly || exportJsonOnly) return documentXml; + // SD-3240: converter surface returns `string | Record` + // (the JSON-only branch returns the intermediate xml-js tree). + // The Editor.exportDocx implementation signature here declares the + // outer union as `Record` which is a pre- + // existing narrower shape than the runtime JSON tree. Cast at the + // bridge so the public surface stays honest. + if (exportXmlOnly || exportJsonOnly) return documentXml as string | Record; const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]); const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml'].elements[0]); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index 81082eb4b6..3e6c2d6f24 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -29,12 +29,17 @@ type MinimalConverterContext = { }; /** - * Extended Editor interface that includes the converter property. - * Used for type-safe access to header/footer data stored in the converter. + * SD-3240: `Editor.converter` is now typed as `EditorConverterSurface` + * (a public no-`any` facade) rather than the raw `SuperConverter` + * class. Header/footer code reads narrower-than-surface fields + * (`headers` / `footers` / `headerIds` / `footerIds` as the + * `HeaderFooterCollections` shape). Cast at the boundary instead of + * declaring an `interface … extends Editor` that overrides + * `converter` incompatibly with the surface. */ -interface EditorWithConverter extends Editor { +type EditorWithConverter = Omit & { converter: HeaderFooterCollections; -} +}; export type HeaderFooterKind = 'header' | 'footer'; export type HeaderFooterVariant = (typeof HEADER_FOOTER_VARIANTS)[number]; @@ -184,12 +189,18 @@ export class HeaderFooterEditorManager extends EventEmitter { } /** - * Type guard to check if an editor has a converter property. + * Runtime check that the editor has a usable converter handle. + * + * SD-3240: cannot be a type predicate (`editor is EditorWithConverter`) + * because `Editor.converter` is `EditorConverterSurface` while + * `EditorWithConverter` overrides it to `HeaderFooterCollections`. + * The two shapes don't share a subtype relationship. Callers narrow + * with a local cast after the check. * * @param editor - The editor instance to check * @returns True if the editor has a converter property */ - #hasConverter(editor: Editor): editor is EditorWithConverter { + #hasConverter(editor: Editor): boolean { return 'converter' in editor && editor.converter !== undefined && editor.converter !== null; } @@ -905,7 +916,7 @@ export class HeaderFooterEditorManager extends EventEmitter { if (!this.#hasConverter(this.#editor)) { return; } - const converter = this.#editor.converter as Record; + const converter = this.#editor.converter as unknown as Record; if (!converter) return; const targetKey = descriptor.kind === 'header' ? 'headerEditors' : 'footerEditors'; @@ -940,7 +951,7 @@ export class HeaderFooterEditorManager extends EventEmitter { if (!this.#hasConverter(this.#editor)) { return; } - const converter = this.#editor.converter as Record; + const converter = this.#editor.converter as unknown as Record; if (!converter) return; const targetKey = descriptor.kind === 'header' ? 'headerEditors' : 'footerEditors'; @@ -1329,7 +1340,7 @@ export class HeaderFooterLayoutAdapter { const blockIdPrefix = `hf-${descriptor.kind}-${descriptor.id}-`; const converterContext = this.#getConverterContext(); - const rootConverter = (this.#manager.rootEditor as EditorWithConverter | undefined)?.converter as + const rootConverter = (this.#manager.rootEditor as unknown as EditorWithConverter | undefined)?.converter as | { media?: Record; getDocumentDefaultStyles?: () => { typeface?: string; fontSizePt?: number } } | undefined; const providedMedia = this.#mediaFiles; @@ -1374,7 +1385,7 @@ export class HeaderFooterLayoutAdapter { if (!('converter' in rootEditor)) { return undefined; } - const converter = (rootEditor as EditorWithConverter).converter as Record | undefined; + const converter = (rootEditor as unknown as EditorWithConverter).converter as Record | undefined; if (!converter) return undefined; const context: ConverterContext = { diff --git a/packages/super-editor/src/editors/v1/core/helpers/getActiveFormatting.js b/packages/super-editor/src/editors/v1/core/helpers/getActiveFormatting.js index 52ccfb5367..e6bd2ffe55 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/getActiveFormatting.js +++ b/packages/super-editor/src/editors/v1/core/helpers/getActiveFormatting.js @@ -1,6 +1,33 @@ import { getMarksFromSelection } from './getMarksFromSelection.js'; import { findMark } from './findMark.js'; +/** + * Result entry from `getActiveFormatting`. Discriminated union: the + * synthetic `copyFormat` flag uses `attrs: true`; every other entry + * carries a `Record` attribute object. + * + * @typedef {{ name: 'copyFormat'; attrs: true } + * | { name: string; attrs: Record }} ActiveFormattingEntry + */ + +/** + * Narrow structural editor shape consumed by `getActiveFormatting`. + * Only `state` (PM EditorState) + `storage.formatCommands.storedStyle` + * are needed. Avoids resurfacing SD-3240 debt through full Editor. + * + * @typedef {{ + * state: import('prosemirror-state').EditorState; + * storage: { formatCommands?: { storedStyle?: unknown } }; + * }} ActiveFormattingEditorLike + */ + +/** + * Compute the active formatting at the current selection. SD-3213 / + * SD-3245: typed signature replaces previous `(editor: any): any`. + * + * @param {ActiveFormattingEditorLike} editor + * @returns {ActiveFormattingEntry[]} + */ export function getActiveFormatting(editor) { const { state } = editor; const { selection } = state; diff --git a/packages/super-editor/src/editors/v1/core/helpers/list-level-formatting-helpers.js b/packages/super-editor/src/editors/v1/core/helpers/list-level-formatting-helpers.js index 1c9b8e4ef7..8578e7bb54 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/list-level-formatting-helpers.js +++ b/packages/super-editor/src/editors/v1/core/helpers/list-level-formatting-helpers.js @@ -1267,7 +1267,9 @@ function copySequenceStateOverrides(editor, fromNumId, toNumId, levels) { for (const sourceEl of sourceNumDef.elements) { if (sourceEl.name !== 'w:lvlOverride') continue; - const ilvl = sourceEl.attributes?.['w:ilvl']; + // SD-3240: OOXML attribute values are typed as `unknown`; the + // level set expects strings, which is what the parser produces. + const ilvl = /** @type {string | null | undefined} */ (sourceEl.attributes?.['w:ilvl']); if (ilvl == null) continue; if (levelSet && !levelSet.has(ilvl)) continue; diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts index 899b134802..98f1ad5ecd 100644 --- a/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts +++ b/packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts @@ -18,9 +18,39 @@ import type { OrderedListStyle } from '../../../extensions/types/paragraph-comma // Types // --------------------------------------------------------------------------- +/** + * SD-3240: OOXML element subtree as held inside `NumberingModel` + * records. Recursive; each element has an optional name, attributes + * map, and nested children. + */ +export interface NumberingElement { + name?: string; + attributes?: Record; + elements?: NumberingElement[]; + [key: string]: unknown; +} + +/** + * SD-3240: minimal shape internal callers read from numbering + * abstract / definition records. The OOXML element tree lives at + * `.elements`; specific tag-level fields are accessed via deeper + * indexing that callers narrow locally. + */ +export interface NumberingRecord { + name?: string; + attributes?: Record; + elements?: NumberingElement[]; + [key: string]: unknown; +} + export interface NumberingModel { - abstracts: Record; - definitions: Record; + // SD-3240: changed from `Record` to a structural type + // with `.elements`. Internal callers read `.elements` to walk the + // OOXML tree; deeper field access goes through local casts. The + // change drains the audit findings reachable through + // `editor.converter.numbering.abstracts` / `.definitions`. + abstracts: Record; + definitions: Record; } interface GenerateOptions { @@ -293,7 +323,10 @@ export function generateNewListDefinition(numbering: NumberingModel, options: Ge if (level != null && start != null && text != null && fmt != null) { if (numbering.definitions[numId]) { - const abstractId = numbering.definitions[numId]?.elements[0]?.attributes['w:val']; + // SD-3240: attribute values are typed as `unknown` (OOXML attrs + // can be any primitive). Cast to number for indexing into the + // typed `abstracts` map. + const abstractId = numbering.definitions[numId]?.elements?.[0]?.attributes?.['w:val'] as number; newAbstractId = abstractId; const abstract = numbering.abstracts[abstractId]; newAbstractDef = { ...abstract }; @@ -354,7 +387,9 @@ export function changeNumIdSameAbstract( const newId = getNextId(numbering.definitions); const def = numbering.definitions[numId]; - const abstractId = def?.elements?.find((el: any) => el.name === 'w:abstractNumId')?.attributes?.['w:val']; + const abstractId = def?.elements?.find((el: NumberingElement) => el.name === 'w:abstractNumId')?.attributes?.[ + 'w:val' + ] as number | undefined; const abstract = abstractId != null ? numbering.abstracts[abstractId] : undefined; if (!abstract) { @@ -386,7 +421,7 @@ export function removeListDefinitions(numbering: NumberingModel, listId: number) const def = numbering.definitions[listId]; if (!def) return; - const abstractId = def.elements?.[0]?.attributes?.['w:val']; + const abstractId = def.elements?.[0]?.attributes?.['w:val'] as number | undefined; delete numbering.definitions[listId]; if (abstractId != null) delete numbering.abstracts[abstractId]; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 54b57773f1..ce7ff34ae8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -6116,15 +6116,20 @@ export class PresentationEditor extends EventEmitter { } } + // SD-3240: converter.convertedXml / translatedLinkedStyles / + // translatedNumbering are typed on the public surface as + // narrower (unknown-bearing) shapes than ConverterContext + // requires. Cast at the boundary; the runtime values match + // the shape ConverterContext expects. converterContext = converter - ? { + ? ({ docx: converter.convertedXml, ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), ...(Object.keys(endnoteNumberById).length ? { endnoteNumberById } : {}), translatedLinkedStyles: converter.translatedLinkedStyles, translatedNumbering: converter.translatedNumbering, ...(defaultTableStyleId ? { defaultTableStyleId } : {}), - } + } as unknown as ConverterContext) : undefined; const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); const positionMapStart = perfNow(); @@ -6143,7 +6148,10 @@ export class PresentationEditor extends EventEmitter { enableTrackedChanges: this.#trackedChangesEnabled, enableComments: commentsEnabled, enableRichHyperlinks: true, - themeColors: this.#editor?.converter?.themeColors ?? undefined, + // SD-3240: converter.themeColors is `unknown` on the public + // EditorConverterSurface; cast to the consumer-expected type + // here. The runtime shape matches at call time. + themeColors: (this.#editor?.converter?.themeColors ?? undefined) as Record | undefined, converterContext, flowBlockCache: this.#flowBlockCache, showBookmarks: this.#layoutOptions.showBookmarks ?? false, diff --git a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts new file mode 100644 index 0000000000..e7d05f71b7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts @@ -0,0 +1,196 @@ +/** + * Public no-`any` surface interfaces for `Editor.converter` and + * `Editor.extensionService` (SD-3240). + * + * The raw `SuperConverter` / `ExtensionService` classes keep a + * `[key: string]: any` catchall in their `.d.ts` shims for internal- + * implementation members. The `Editor` class fields are typed as + * these surface interfaces (not the raw classes), so the public + * type graph stops here and does not leak `any` through any + * `editor.converter.X` or `editor.extensionService.X` reach path. + * + * The runtime instance is still the raw class; a single `as unknown + * as ` cast lives at the assignment boundary in `Editor.ts` + * `#createConverter` / `#initServiceExtensions`. Internal code that + * needs deeper member access uses a local narrow interface and a + * cast at the call site (no `any`). + */ +import type { Plugin } from 'prosemirror-state'; +import type { Schema } from 'prosemirror-model'; +import type { NodeViewConstructor } from 'prosemirror-view'; +import type { EditorHelpers } from './EditorTypes.js'; +import type { Comment } from './EditorEvents.js'; +import type { EditorExtension } from './EditorConfig.js'; +import type { CommandProps } from './ChainedCommands.js'; +import type { ExtensionAttribute } from '../Attribute.js'; +import type { NumberingModel } from '../parts/adapters/numbering-transforms.js'; + +/** + * Loosely-typed OOXML part as held in `convertedXml`. Element trees + * are recursive (each `elements[]` entry is another `ConvertedXmlPart`) + * and mutable (internal callers do `part.elements = [...]` rewrites). + */ +export interface ConvertedXmlPart { + name?: string; + type?: string; + attributes?: Record; + elements?: ConvertedXmlPart[]; + [key: string]: unknown; +} + +/** + * Header/footer rels-ID map keyed by section variant + * (`default` / `first` / `even`). Values can be string, array of + * strings, boolean flag, or absent depending on the section's state. + */ +export type HeaderFooterIdMap = Record; + +/** Item shape for `headerEditors` / `footerEditors` arrays. */ +export interface HeaderFooterEditorEntry { + editor?: { destroy?: () => void } & Record; + [key: string]: unknown; +} + +/** Public surface of `Editor.converter`. SD-3240: no `any`. */ +export interface EditorConverterSurface { + // --- Plain data members --- + addedMedia: Record; + bodySectPr: unknown; + comments: Comment[]; + commentThreadingProfile: unknown; + convertedXml: Record; + declaration: unknown; + docHiglightColors: unknown; + documentAttributes: unknown; + documentGuid: string | null; + documentModified: boolean; + footerEditors: HeaderFooterEditorEntry[]; + footerIds: HeaderFooterIdMap; + footers: Record; + footnoteProperties: unknown; + headerEditors: HeaderFooterEditorEntry[]; + headerFooterModified: boolean; + headerIds: HeaderFooterIdMap; + headers: Record; + importedBodyHasFooterRef: boolean; + importedBodyHasHeaderRef: boolean; + /** + * Typed array of linked-style records (each carries an `id` and + * an optional nested `definition.styles`). Wider unknown extras + * are accepted via the trailing index signature. + */ + linkedStyles: Array<{ + id?: string | number; + definition?: { styles?: Record }; + [key: string]: unknown; + }>; + numbering: NumberingModel; + /** + * Raw converter page styles: `pageSize` / `pageMargins` shape as + * parsed from `w:sectPr`. Includes `alternateHeaders?: boolean` + * read by the document-settings adapter + * (`ConverterWithDocumentSettings.pageStyles`). + * NOT the consumer-facing `PageStyles` flattened shape. + */ + pageStyles: { + pageSize?: { width?: number; height?: number }; + pageMargins?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + header?: number; + footer?: number; + }; + alternateHeaders?: boolean; + [key: string]: unknown; + }; + savedTagsToRestore: unknown; + themeColors: unknown; + translatedLinkedStyles: unknown; + /** + * Translated numbering model: same `abstracts` / `definitions` + * shape as `numbering` but with rendered values applied. Internal + * helpers iterate both maps. + */ + translatedNumbering: { abstracts?: Record; definitions?: Record }; + + // --- Methods --- + /** + * Convert the current document tree to DOCX XML. Returns the + * exported XML string by default, or the intermediate + * `Record` JSON tree when called with + * `exportJsonOnly: true`. The `Blob` / `Buffer` wrapping happens + * upstream in `Editor.exportDocx()` (which feeds the result into + * a zipper), not here. + */ + exportToDocx(...args: unknown[]): Promise>; + getBibliographyPartExportPaths(): readonly string[]; + /** + * ISO-8601 `dcterms:created` timestamp from core.xml (e.g. + * `'2024-01-15T10:30:00Z'`), or `null` if core.xml is missing or + * has no created element. + */ + getDocumentCreatedTimestamp(): string | null; + /** + * Document default styles for font rendering: typeface, font size + * (pt), and CSS font-family stack. Used by ProseMirrorRenderer to + * configure the default editor styles. + */ + getDocumentDefaultStyles(): + | { + typeface?: string; + fontSizePt?: number; + fontFamilyCss?: string; + [key: string]: unknown; + } + | null + | undefined; + getDocumentFonts(): string[]; + /** + * Async. Returns the stable document identifier (GUID-based + * `identifierHash` when both GUID and timestamp exist, otherwise a + * `contentHash` and a backfilled GUID/timestamp pair). Resolves to + * a non-null string in every code path; the `null` fallback lives + * on `Editor.getDocumentIdentifier()` for the converter-missing + * case. + */ + getDocumentIdentifier(): Promise; + /** Returns `{ styleString, fontsImported }` for font face injection. */ + getFontFaceImportString(): + | { + styleString?: string; + fontsImported?: string[]; + [key: string]: unknown; + } + | null + | undefined; + getSchema(): unknown; + getSuperdocVersion(): string | null; + promoteToGuid(): void; + schemaToXml(element: unknown): string; +} + +/** + * Curried command callable: `(...args) => (props) => boolean`, + * matching the runtime pattern in `CommandService.js`. + */ +export type SurfaceCommandCallable = (...args: unknown[]) => (props: CommandProps) => boolean; + +/** Public surface of `Editor.extensionService`. SD-3240: no `any`. */ +export interface EditorExtensionServiceSurface { + attributes: ExtensionAttribute[]; + commands: Record; + /** + * Registered extensions. Each entry is an `EditorExtension` + * (node/mark/extension) with a runtime `isExternal?` flag set by + * the importer pipeline. + */ + extensions: Array; + externalExtensions: readonly EditorExtension[]; + helpers: EditorHelpers; + nodeViews: { [node: string]: NodeViewConstructor }; + plugins: readonly Plugin[]; + schema: Schema; + splittableMarks: readonly string[]; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/diff-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/diff-adapter.ts index c647eab726..4033de5418 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/diff-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/diff-adapter.ts @@ -20,6 +20,7 @@ import { compareToSnapshot, applyDiffPayload, DiffServiceError, + type DiffServiceEditor, } from '../extensions/diffing/service/index'; import { DocumentApiAdapterError } from './errors.js'; @@ -27,17 +28,23 @@ import { DocumentApiAdapterError } from './errors.js'; * Creates a DiffAdapter bound to the given editor instance. */ export function createDiffAdapter(editor: Editor): DiffAdapter { + // SD-3240: DiffServiceEditor narrows `converter` to specific diff- + // related shapes (StylesDocumentProperties, NumberingProperties) + // that overlap with, but don't structurally match, + // EditorConverterSurface. Cast at the boundary; runtime shape is + // identical. + const diffEditor = editor as unknown as DiffServiceEditor; return { capture(): DiffSnapshot { - return wrapServiceCall(() => captureSnapshot(editor)); + return wrapServiceCall(() => captureSnapshot(diffEditor)); }, compare(input: DiffCompareInput): DiffPayload { - return wrapServiceCall(() => compareToSnapshot(editor, input.targetSnapshot)); + return wrapServiceCall(() => compareToSnapshot(diffEditor, input.targetSnapshot)); }, apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult { - const { result, tr } = wrapServiceCall(() => applyDiffPayload(editor, input.diff, options)); + const { result, tr } = wrapServiceCall(() => applyDiffPayload(diffEditor, input.diff, options)); if (tr.docChanged || result.appliedOperations > 0) { editor.dispatch(tr); diff --git a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts index a5da9da4e6..87d5347e76 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/formatting.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/formatting.ts @@ -7,6 +7,27 @@ import { getCurrentResolvedParagraphProperties, isFieldAnnotationSelection, reso import { createDirectCommandExecute, isCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; +/** + * Local mirror of `ActiveFormattingEntry` from `getActiveFormatting.js` + * (the JS typedef isn't re-exportable cleanly from TS). Discriminated + * union: `copyFormat` uses a boolean `attrs: true` sentinel, every + * other entry carries a real attrs record. + */ +type FormattingEntry = { name: 'copyFormat'; attrs: true } | { name: string; attrs: Record }; + +type FormattingEntryWithAttrs = Extract }>; + +const hasFormattingAttrs = (entry: FormattingEntry): entry is FormattingEntryWithAttrs => { + return typeof entry.attrs === 'object' && entry.attrs !== null; +}; + +const getFormattingAttr = (entries: FormattingEntry[], name: string, attr: string): unknown[] => { + return entries + .filter((entry): entry is FormattingEntryWithAttrs => entry.name === name && hasFormattingAttrs(entry)) + .map((entry) => entry.attrs[attr]) + .filter((value) => value != null); +}; + export const normalizeFontSizeValue = (value: unknown) => { if (typeof value === 'number') { return `${value}pt`; @@ -59,12 +80,10 @@ export const isFormattingActivatedFromLinkedStyle = ( return result; }; -export const hasNegatedFormattingMark = ( - formatting: Array<{ name: string; attrs?: Record }>, - markName: string, -) => { +export const hasNegatedFormattingMark = (formatting: FormattingEntry[], markName: string) => { const rawActiveMark = formatting.find((mark) => mark.name === markName); - return rawActiveMark ? isNegatedMark(rawActiveMark.name, rawActiveMark.attrs) : false; + if (!rawActiveMark || !hasFormattingAttrs(rawActiveMark)) return false; + return isNegatedMark(rawActiveMark.name, rawActiveMark.attrs); }; type FormatCommandsStorage = { @@ -195,10 +214,7 @@ export const createFontSizeStateDeriver = }; } - const values = formatting - .filter((mark) => mark.name === 'fontSize') - .map((mark) => mark.attrs?.fontSize) - .filter((value) => value != null); + const values = getFormattingAttr(formatting, 'fontSize', 'fontSize'); const normalizedValues = values.map((value) => normalizeFontSizeValue(value)); const uniqueValues = [...new Set(normalizedValues)]; @@ -236,10 +252,7 @@ export const createFontFamilyStateDeriver = }; } - const values = formatting - .filter((mark) => mark.name === 'fontFamily') - .map((mark) => mark.attrs?.fontFamily) - .filter((value) => value != null); + const values = getFormattingAttr(formatting, 'fontFamily', 'fontFamily'); const normalizedValues = values.map((value) => normalizeFontFamilyValue(value)); const uniqueValues = [...new Set(normalizedValues)]; @@ -281,10 +294,7 @@ export const createTextColorStateDeriver = }; } - const values = formatting - .filter((mark) => mark.name === 'color') - .map((mark) => mark.attrs?.color) - .filter((value) => value != null); + const values = getFormattingAttr(formatting, 'color', 'color'); const markNegated = hasNegatedFormattingMark(formatting, 'color'); const normalizedValues = values.map((value) => normalizeColorValue(value)); @@ -313,10 +323,7 @@ export const createHighlightColorStateDeriver = }; } - const values = formatting - .filter((mark) => mark.name === 'highlight') - .map((mark) => mark.attrs?.color) - .filter((value) => value != null); + const values = getFormattingAttr(formatting, 'highlight', 'color'); const markNegated = hasNegatedFormattingMark(formatting, 'highlight'); const normalizedValues = values.map((value) => normalizeColorValue(value)); @@ -345,10 +352,7 @@ export const createLinkStateDeriver = }; } - const values = formatting - .filter((mark) => mark.name === 'link') - .map((mark) => mark.attrs?.href) - .filter((value) => value != null); + const values = getFormattingAttr(formatting, 'link', 'href'); const normalizedValues = values.map((value) => normalizeLinkHrefValue(value)); const uniqueValues = [...new Set(normalizedValues)]; diff --git a/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json b/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json index 463ebe0110..b5176cca24 100644 --- a/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json +++ b/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json @@ -1,187 +1,6 @@ { "version": 1, "scope": "supported-root", - "generatedAt": "2026-05-21T19:18:32.457Z", - "entries": [ - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|Editor..new(options)<0>.extensions[].editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "Editor..new(options)<0>.extensions[].editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|Editor..new(options)<0>.extensions[].editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "Editor..new(options)<0>.extensions[].editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|Extensions..Node.new(config).editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "Extensions..Node.new(config).editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|Extensions..Node.new(config).editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "Extensions..Node.new(config).editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|SuperDoc..new(config).modules.comments.permissionResolver(params).superdoc.activeEditor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "SuperDoc..new(config).modules.comments.permissionResolver(params).superdoc.activeEditor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|SuperDoc..new(config).modules.comments.permissionResolver(params).superdoc.activeEditor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "SuperDoc..new(config).modules.comments.permissionResolver(params).superdoc.activeEditor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|SuperDoc.config.modules.links.popoverResolver(ctx).editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "SuperDoc.config.modules.links.popoverResolver(ctx).editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|SuperDoc.config.modules.links.popoverResolver(ctx).editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "SuperDoc.config.modules.links.popoverResolver(ctx).editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|defineNode.(config).editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "defineNode.(config).editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|defineNode.(config).editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "defineNode.(config).editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getRichTextExtensions.=>return[].editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "getRichTextExtensions.=>return[].editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getRichTextExtensions.=>return[].editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "getRichTextExtensions.=>return[].editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getSchemaIntrospection.(options).editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "getSchemaIntrospection.(options).editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getSchemaIntrospection.(options).editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "getSchemaIntrospection.(options).editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getStarterExtensions.=>return[].editor.converter[string]|converter: SuperConverter;", - "kind": "index", - "symbolPath": "getStarterExtensions.=>return[].editor.converter[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 154, - "snippet": "converter: SuperConverter;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "index|node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts|getStarterExtensions.=>return[].editor.extensionService[string]|extensionService: ExtensionService;", - "kind": "index", - "symbolPath": "getStarterExtensions.=>return[].editor.extensionService[string]", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/Editor.d.ts", - "line": 104, - "snippet": "extensionService: ExtensionService;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "param|node_modules/superdoc/dist/super-editor/src/editors/v1/core/helpers/getActiveFormatting.d.ts|getActiveFormatting.(editor)|editor: any", - "kind": "param", - "symbolPath": "getActiveFormatting.(editor)", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/helpers/getActiveFormatting.d.ts", - "line": 1, - "snippet": "editor: any", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - }, - { - "key": "return|node_modules/superdoc/dist/super-editor/src/editors/v1/core/helpers/getActiveFormatting.d.ts|getActiveFormatting.=>return|export function getActiveFormatting(editor: any): any;", - "kind": "return", - "symbolPath": "getActiveFormatting.=>return", - "file": "node_modules/superdoc/dist/super-editor/src/editors/v1/core/helpers/getActiveFormatting.d.ts", - "line": 1, - "snippet": "export function getActiveFormatting(editor: any): any;", - "owner": "tier-5-other", - "rationale": "auto-seeded from inventory (supported-root scope)" - } - ] + "generatedAt": "2026-05-21T22:55:59.484Z", + "entries": [] } diff --git a/tests/consumer-typecheck/src/editor-surfaces-not-any.ts b/tests/consumer-typecheck/src/editor-surfaces-not-any.ts new file mode 100644 index 0000000000..c6a98bea5a --- /dev/null +++ b/tests/consumer-typecheck/src/editor-surfaces-not-any.ts @@ -0,0 +1,101 @@ +/** + * Consumer typecheck: Editor.converter, Editor.extensionService, and + * getActiveFormatting are typed surfaces, not `any` (SD-3240, SD-3245). + * + * Before this change: + * - `editor.converter` resolved to `SuperConverter` with a + * `[key: string]: any` catchall. + * - `editor.extensionService` resolved to `ExtensionService` with a + * `[key: string]: any` catchall. + * - `getActiveFormatting` was typed `(editor: any): any`. + * + * 18 supported-root allowlist entries (16 + 2) flowed from those three + * `any` shapes. After SD-3240, the Editor field types are public + * surface interfaces with `unknown` extras; after SD-3245, the helper + * has a real signature. The allowlist drains to 0; this fixture locks + * the contract so a regression breaks the build, not just a JSON file. + */ + +import { Editor, getActiveFormatting } from 'superdoc/super-editor'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +// --- editor.converter is not any ----------------------------------------- + +type EditorConverterT = Editor['converter']; +// If `converter` were `any`, `Equal` would be `true`. +// Asserting `false` proves it's a real interface. +const _converterIsNotAny: Equal = false; +void _converterIsNotAny; + +// --- editor.extensionService is not any ---------------------------------- + +type EditorExtensionServiceT = Editor['extensionService']; +const _extServiceIsNotAny: Equal = false; +void _extServiceIsNotAny; + +// --- getActiveFormatting input is not any -------------------------------- + +type GetActiveFormattingParam = Parameters[0]; +const _paramIsNotAny: Equal = false; +void _paramIsNotAny; + +// --- getActiveFormatting return is not any (and element is not any) ------ + +type GetActiveFormattingReturn = ReturnType; +const _returnIsNotAny: Equal = false; +void _returnIsNotAny; + +// An `any[]` would compare-equal to `unknown[]` here; using a fresh +// `any` element check disambiguates: if the element type drifted to +// `any`, this becomes `true`. +type GetActiveFormattingElement = GetActiveFormattingReturn extends Array ? E : never; +const _elementIsNotAny: Equal = false; +void _elementIsNotAny; + +// --- Reach through editor.converter: known surface members stay typed --- + +// `documentGuid` is declared on the public surface as `string | null`. +// If the field type regresses to `any`, this exact-type check breaks. +declare const editor: Editor; +const guid: string | null = editor.converter.documentGuid; +void guid; +const _guidIsExact: Equal = true; +void _guidIsExact; + +// `getDocumentCreatedTimestamp()` returns an ISO timestamp string +// (e.g. `'2024-01-15T10:30:00Z'`) or `null`. SD-3240 review caught a +// surface-vs-runtime mismatch where the field was originally typed as +// `number | null`; this assertion pins the correct shape so a future +// drift back to number (or any) fails the typecheck matrix. +const createdAt: string | null = editor.converter.getDocumentCreatedTimestamp(); +void createdAt; +const _createdAtIsExact: Equal, string | null> = true; +void _createdAtIsExact; + +// `getDocumentIdentifier()` is async at the converter level (the +// `null` fallback lives on `Editor.getDocumentIdentifier()` for the +// converter-missing case). The original surface mistakenly typed it as +// synchronous `string | null`; this assertion pins `Promise`. +const identifier: Promise = editor.converter.getDocumentIdentifier(); +void identifier; +const _identifierIsExact: Equal, Promise> = true; +void _identifierIsExact; + +// `exportToDocx()` at the converter level returns either the rendered +// XML string or the intermediate xml-js JSON tree (when called with +// `exportJsonOnly: true`). Blob / Buffer wrapping happens upstream in +// `Editor.exportDocx()`, not on the converter. This assertion pins the +// honest converter shape so a future regression to `any` (or back to +// the original Blob | Buffer fiction) breaks the typecheck matrix. +// (Note: `Editor.exportDocx({ exportJsonOnly: true })` is publicly +// typed as `Promise` but actually returns a JSON tree at +// runtime; that overload correction is tracked separately and is +// out of scope for SD-3240.) +const exported: Promise> = editor.converter.exportToDocx(); +void exported; +const _exportedIsExact: Equal< + ReturnType, + Promise> +> = true; +void _exportedIsExact; diff --git a/tests/consumer-typecheck/typecheck-matrix.mjs b/tests/consumer-typecheck/typecheck-matrix.mjs index 31681c9ddb..f8e2541f77 100644 --- a/tests/consumer-typecheck/typecheck-matrix.mjs +++ b/tests/consumer-typecheck/typecheck-matrix.mjs @@ -633,6 +633,31 @@ const scenarios = [ files: ['src/extensions-helpers.ts'], mustPass: true, }, + // SD-3240 / SD-3245: editor.converter, editor.extensionService, and + // getActiveFormatting are typed surfaces, not `any`. Drains the final + // 18 supported-root allowlist entries (16 + 2) to 0. The fixture's + // `Equal` checks regress the build if any of those surfaces + // widen back to `any`. + { + name: 'bundler / editor surfaces not any (SD-3240)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/editor-surfaces-not-any.ts'], + mustPass: true, + }, + { + name: 'node16 / editor surfaces not any (SD-3240)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/editor-surfaces-not-any.ts'], + mustPass: true, + }, // SD-3213: NodeConfig.renderDOM uses a local SuperDocDOMOutputSpec // alias instead of PM's DOMOutputSpec (which contains // `readonly [string, ...any[]]`). Pins the four consumer shapes