diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml index f034ddd77f..3387fb3dcc 100644 --- a/.github/workflows/ci-behavior.yml +++ b/.github/workflows/ci-behavior.yml @@ -66,7 +66,7 @@ jobs: # detect gate above. When the suite does run, exercise all 3 browsers # so cross-browser regressions are caught at PR time. browser: [chromium, firefox, webkit] - shard: [1, 2, 3, 4, 5] + shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v6 @@ -105,8 +105,8 @@ jobs: run: pnpm exec playwright install-deps ${{ matrix.browser }} working-directory: tests/behavior - - name: Run behavior tests (${{ matrix.browser }} shard ${{ matrix.shard }}/5) - run: pnpm exec playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/5 + - name: Run behavior tests (${{ matrix.browser }} shard ${{ matrix.shard }}/6) + run: pnpm exec playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/6 working-directory: tests/behavior validate: diff --git a/.github/workflows/ci-demos.yml b/.github/workflows/ci-demos.yml index 47da237ef3..22db83c3d8 100644 --- a/.github/workflows/ci-demos.yml +++ b/.github/workflows/ci-demos.yml @@ -59,16 +59,11 @@ jobs: - contract-templates - custom-ui - docx-from-html - - docxtemplater - - fields + - fields-source - grading-papers # - html-editor # broken: imports unpublished superdoc/super-editor/style.css subpath - linked-sections - - loading-from-json - nextjs-ssr - # - replace-content # broken: runtime nextSibling error in SuperDoc - - text-selection - - toolbar steps: - name: Restore workspace uses: actions/cache/restore@v4 diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml index 618efc2e33..ba18ee4508 100644 --- a/.github/workflows/ci-examples.yml +++ b/.github/workflows/ci-examples.yml @@ -57,7 +57,7 @@ jobs: strategy: fail-fast: false matrix: - example: [react, vue, vanilla, cdn, angular, nuxt, laravel] + example: [react, vue, vanilla, cdn, angular, nuxt, laravel, solid] steps: - name: Restore workspace uses: actions/cache/restore@v4 diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index 6c36a5553f..aac05c9fd8 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -120,32 +120,20 @@ jobs: # tree (those are tracked under SD-2863 follow-up tickets). run: pnpm --filter superdoc run check:jsdoc - - name: Consumer typecheck (matrix) - # The matrix script owns the published-package validation path: - # it packs superdoc, installs the tarball into the standalone - # fixture (`pnpm install --ignore-workspace`), then runs every - # scenario under its declared resolution mode and strictness - # settings. Replaces the pre-SD-2831 bare `tsc --noEmit` step - # so the new pack-and-install scenarios are actually exercised - # in CI, not just locally. - run: | - cd tests/consumer-typecheck - node typecheck-matrix.mjs - - - name: Deep public-type audit (report-only) - # Recursive walk of every type reachable from superdoc's public - # exports in the installed tarball. Reports inventory by tier and - # top files. Always exits 0 in default mode; the `--strict` flag - # turns it into a hard gate but is not used in CI yet because the - # current public surface is the accidental declaration graph, not - # a deliberate facade. SD-2966 will define that facade; once it - # lands, this step gets `--strict` added and an allowlist file is - # seeded against the facade-scoped findings. Until then, the step - # provides visibility without the maintenance burden of a giant - # public allowlist. - run: | - cd tests/consumer-typecheck - node deep-type-audit.mjs + - name: Public-contract check (matrix + supported-root strict audit) + # SD-673 Phase 1: collapse the previous two CI steps + # ('Consumer typecheck (matrix)' + 'Deep public-type audit') into + # the single wrapper command. Same coverage: + # - typecheck-matrix.mjs packs superdoc, installs the tarball + # into the consumer fixture, runs every scenario. + # - deep-type-audit.mjs --strict-supported-root reuses that + # install and gates on the supported-root any allowlist + # (SD-3213e). Broad inventory still printed for visibility. + # --skip-build because the Build step above already ran + # `pnpm run build` (which includes build:superdoc). + # Local equivalent: `pnpm check:public-contract` (with the build + # stage included). + run: pnpm check:public-contract --skip-build - name: Package shape gates # External package-shape linters (publint + attw) running against @@ -154,14 +142,24 @@ jobs: # ESM, missing CDN files, unpublished `source` paths. run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) - # No-growth snapshots for the legacy public compatibility surfaces. - # See tests/consumer-typecheck/snapshots/README.md for the policy. - # Runs after the matrix step so the packed-and-installed fixture - # is available for Snapshot B (resolved named exports). - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check + - name: Public surface no-growth snapshots (SD-3176, SD-3212) + # Unified entry point for the three snapshot families: + # - super-editor-package: @superdoc/super-editor package.json#exports keys + # - legacy: resolved exports for superdoc/* legacy subpaths + # - root: 4-source inventory (types.import, types.require, import, + # require) for the superdoc root entry. Cross-source mismatches + # are reported in the companion .md but are not blockers on their + # own. + # Runs after the matrix step so the packed-and-installed fixture is + # available. See tests/consumer-typecheck/snapshots/README.md. + run: node tests/consumer-typecheck/snapshot.mjs --all --check + + - name: Root classification closure gate (SD-3212 PR A1b) + # Asserts the dependency-closure rule from the A1 classification: + # no supported-root or legacy-root exported root symbol may reference + # an internal-candidate root symbol in its public declared type. + # Catches the failure class behind Phase 4a's 31-failure dry-run. + run: node tests/consumer-typecheck/check-root-classification-closure.mjs unit-tests: needs: build diff --git a/.github/workflows/promote-stable-docs.yml b/.github/workflows/promote-stable-docs.yml index e83e8b525f..ccafb3fa27 100644 --- a/.github/workflows/promote-stable-docs.yml +++ b/.github/workflows/promote-stable-docs.yml @@ -1,10 +1,11 @@ # Advances docs-stable to the current stable HEAD (or a chosen SHA). # # Auto path (workflow_run): release-stable.yml is the orchestrator that -# releases superdoc on stable, so we trigger off its completion and gate on -# whether a real v* tag appeared between the triggering run's head_sha and -# origin/stable. Tools-only runs (CLI/SDK/MCP without a superdoc release) -# leave docs-stable unchanged - no new v* tag, no push. +# releases superdoc on stable, so we trigger off its completion, wait for the +# shared stable release lane to settle, and gate on whether a real v* tag +# appeared between the triggering run's head_sha and origin/stable. Tools-only +# runs (CLI/SDK/MCP without a superdoc release) leave docs-stable unchanged - +# no new v* tag, no push. # # We accept conclusion: failure as well as success because the orchestrator # runs chains independently. A tools-chain failure that follows a successful @@ -60,6 +61,43 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Wait for stable release lane to drain + if: github.event_name == 'workflow_run' + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + deadline=$((SECONDS + 1800)) + while true; do + active_runs=$(gh run list \ + --repo "$REPO" \ + --branch stable \ + --limit 100 \ + --json databaseId,name,status,url \ + --jq '[.[] | select((.name == "๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder") and .status != "completed")] | length') + + if [ "$active_runs" -eq 0 ]; then + echo "Stable release lane is idle." + break + fi + + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Timed out waiting for stable release lane to drain." + gh run list \ + --repo "$REPO" \ + --branch stable \ + --limit 100 \ + --json databaseId,name,status,url \ + --jq '.[] | select((.name == "๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder") and .status != "completed") | "\(.databaseId)\t\(.name)\t\(.status)\t\(.url)"' + exit 1 + fi + + echo "Waiting for ${active_runs} stable release run(s) to finish..." + sleep 30 + done + # Auto path: gate on a real SuperDoc release between the triggering # run's head_sha and origin/stable. A no-op semantic-release run must # not advance docs-stable. @@ -108,19 +146,32 @@ jobs: - name: Push docs-stable (auto) if: github.event_name == 'workflow_run' && steps.detect.outputs.released == 'true' - run: git push origin "refs/remotes/origin/stable:refs/heads/docs-stable" + run: | + set -euo pipefail + git fetch origin stable docs-stable --tags --force + + docs_only_commits=$(git log --oneline origin/stable..origin/docs-stable -- apps/docs/ || true) + if [ -n "${docs_only_commits}" ]; then + echo "docs-stable has docs changes that are not on stable; refusing to overwrite:" + echo "${docs_only_commits}" + exit 1 + fi + + target=$(git rev-parse origin/stable) + expected=$(git rev-parse origin/docs-stable) + echo "Promoting ${target} to docs-stable with lease ${expected}." + git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable" # Manual path: trust the operator. Promote either the requested SHA - # or the current origin/stable head. The push is rejected by GitHub - # if it isn't a fast-forward, so this stays safe even on operator - # error - no `--force`. + # or the current origin/stable head, while still using a lease so we + # never overwrite a concurrently updated docs-stable branch. - name: Push docs-stable (manual) if: github.event_name == 'workflow_dispatch' env: REQUESTED_SHA: ${{ inputs.sha }} run: | set -euo pipefail - git fetch origin stable --tags --force + git fetch origin stable docs-stable --tags --force if [ -n "${REQUESTED_SHA}" ]; then target="${REQUESTED_SHA}" @@ -128,5 +179,13 @@ jobs: target=$(git rev-parse origin/stable) fi - echo "Promoting ${target} to docs-stable." - git push origin "${target}:refs/heads/docs-stable" + docs_only_commits=$(git log --oneline "${target}"..origin/docs-stable -- apps/docs/ || true) + if [ -n "${docs_only_commits}" ]; then + echo "docs-stable has docs changes that are not on ${target}; refusing to overwrite:" + echo "${docs_only_commits}" + exit 1 + fi + + expected=$(git rev-parse origin/docs-stable) + echo "Promoting ${target} to docs-stable with lease ${expected}." + git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable" diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 734215ecfd..aa32cbc237 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,6 +1,14 @@ # Auto-releases SDK on push to main (@next channel). # Stable releases are orchestrated centrally by release-stable.yml. # Also supports manual dispatch as a fallback for one-off releases. +# +# Python publish routing: +# - push to main (auto-release) -> TestPyPI (Python @next .devN, ephemeral) +# - workflow_dispatch, smoke=true -> TestPyPI only (Python; no npm, no semantic-release) +# - workflow_dispatch, smoke=false -> production PyPI (manual production recovery for stable) +# TestPyPI auth uses an account-scoped API token stored as TEST_PYPI_TOKEN in +# the `testpypi` GitHub environment (not OIDC). The smoke job lives in this +# file so the same token + env scope cover both auto and smoke paths. name: "\U0001F4E6 Release SDK" on: @@ -37,11 +45,16 @@ on: required: false type: string default: latest + smoke: + description: 'TestPyPI smoke test (Python-only, no npm, no semantic-release). Uses 0.0.0-next.${run_number}.' + required: false + type: boolean + default: false permissions: contents: write packages: write - id-token: write # PyPI trusted publishing (OIDC) + id-token: write # Production PyPI trusted publishing (OIDC) on manual-release; TestPyPI uses TEST_PYPI_TOKEN instead. concurrency: # Release runs never cancel an in-progress release (each merge is a release-worthy @@ -60,7 +73,10 @@ jobs: auto-release: if: github.event_name == 'push' runs-on: ubuntu-24.04 - environment: pypi + # Auto path publishes only to TestPyPI to keep production PyPI storage + # reserved for stable X.Y.Z releases. Auth uses TEST_PYPI_TOKEN from the + # `testpypi` GitHub environment (account-scoped TestPyPI API token). + environment: testpypi outputs: released: ${{ steps.detect.outputs.released }} version: ${{ steps.detect.outputs.version }} @@ -182,17 +198,21 @@ jobs: if: steps.detect.outputs.release_present == 'true' run: node packages/sdk/scripts/build-python-sdk.mjs - - name: Publish companion Python packages to PyPI + - name: Publish companion Python packages to TestPyPI if: steps.detect.outputs.release_present == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ packages-dir: packages/sdk/langs/python/companion-dist/ skip-existing: true - - name: Publish main Python SDK to PyPI + - name: Publish main Python SDK to TestPyPI if: steps.detect.outputs.release_present == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ packages-dir: packages/sdk/langs/python/dist/ skip-existing: true @@ -233,7 +253,7 @@ jobs: # Manual fallback (workflow_dispatch) # ------------------------------------------------------------------- manual-release: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && !inputs.smoke runs-on: ubuntu-24.04 environment: ${{ inputs.dry-run && '' || 'pypi' }} steps: @@ -337,3 +357,111 @@ jobs: ls -la packages/sdk/langs/python/companion-dist/ echo "=== Root wheel ===" ls -la packages/sdk/langs/python/dist/ + + # ------------------------------------------------------------------- + # TestPyPI smoke (workflow_dispatch, smoke=true) + # + # Publishes all six Python projects to TestPyPI at a unique + # 0.0.0.dev${run_number} version to validate the publish wiring end to + # end (account-scoped TEST_PYPI_TOKEN from the `testpypi` environment, + # wheel build, all six project names) in one shot. No npm publish, no + # semantic-release tag, no labs dispatch. Use before the first real + # merge on the auto path, and any time the publish wiring changes. + # + # First successful run also creates the six TestPyPI projects under + # the token owner's account. + # + # The 0.0.0.dev* versions left on TestPyPI are swept by the retention + # workflow (PR1b); they have no stable predecessor so the retention + # policy must include them explicitly. + # ------------------------------------------------------------------- + testpypi-smoke: + if: github.event_name == 'workflow_dispatch' && inputs.smoke && github.ref_name == 'main' + runs-on: ubuntu-24.04 + environment: testpypi + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - uses: oven-sh/setup-bun@v2 + with: + # See auto-release for the bun pin rationale (SD-2784). + bun-version: 1.3.11 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install canvas system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential libcairo2-dev libpango1.0-dev \ + libjpeg-dev libgif-dev librsvg2-dev libpixman-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Python build tools + run: pip install build + + - name: Compute smoke version + id: smoke_version + run: | + SEMVER="0.0.0-next.${{ github.run_number }}" + echo "semver=$SEMVER" >> "$GITHUB_OUTPUT" + echo "### TestPyPI smoke" >> "$GITHUB_STEP_SUMMARY" + echo "Publishing all six Python projects at \`$SEMVER\` (PEP 440: \`0.0.0.dev${{ github.run_number }}\`)." >> "$GITHUB_STEP_SUMMARY" + + - name: Set smoke version across SDK packages + run: node packages/sdk/scripts/sync-sdk-version.mjs --set "${{ steps.smoke_version.outputs.semver }}" + + - name: Generate all artifacts + run: pnpm run generate:all + + - name: Build superdoc package for CLI native bundling + run: pnpm --prefix packages/superdoc run build:es + + - name: Verify superdoc build output exists + run: | + test -f packages/superdoc/dist/super-editor.es.js \ + || (echo "FATAL: packages/superdoc/dist/super-editor.es.js missing โ€” build:es likely failed silently" && exit 1) + + # Stage CLI native binaries into Python companion packages. + # build-python-sdk.mjs has a fail-fast prerequisite check that requires + # these binaries; the auto-release path stages them via + # sdk-release-publish.mjs --npm-only, which the smoke job bypasses. + # These three steps map to steps 2, 3, 5 of sdk-release-publish.mjs. + - name: Build CLI native binaries (all platforms) + run: pnpm --prefix apps/cli run build:native:all + + - name: Stage CLI artifacts + run: pnpm --prefix apps/cli run build:stage + + - name: Stage Python companion binaries + run: node packages/sdk/scripts/stage-python-companion-cli.mjs + + - name: Build and verify Python SDK + run: node packages/sdk/scripts/build-python-sdk.mjs + + - name: Publish companion Python packages to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + packages-dir: packages/sdk/langs/python/companion-dist/ + skip-existing: true + + - name: Publish main Python SDK to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + packages-dir: packages/sdk/langs/python/dist/ + skip-existing: true diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 67b39c8246..a7c2200a5d 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -119,18 +119,21 @@ jobs: cd tests/consumer-typecheck node typecheck-matrix.mjs - - name: Deep public-type audit (report-only) - run: | - cd tests/consumer-typecheck - node deep-type-audit.mjs + - name: Deep public-type audit (supported-root strict, SD-3213e) + # Single invocation: broad inventory + supported-root strict gate. + # Same gate as PR CI. Catches releases that bypass PR CI. + run: node tests/consumer-typecheck/deep-type-audit.mjs --strict-supported-root - name: Package shape gates run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check + - name: Public surface no-growth snapshots (SD-3176, SD-3212) + # Unified entry point for all three snapshot families + # (super-editor-package, legacy, root). Same gate as PR CI. + run: node tests/consumer-typecheck/snapshot.mjs --all --check + + - name: Root classification closure gate (SD-3212 PR A1b) + run: node tests/consumer-typecheck/check-root-classification-closure.mjs - name: Release stable packages (orchestrator) id: stable_release diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 96dc2d36f2..a16800bddd 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -136,24 +136,26 @@ jobs: cd tests/consumer-typecheck node typecheck-matrix.mjs - - name: Deep public-type audit (report-only) - # Inventory pass: same as PR CI. Not strict yet (no facade yet - # per SD-2966); ships only the regression report. Once SD-2966 - # lands, swap in `--strict`. - run: | - cd tests/consumer-typecheck - node deep-type-audit.mjs + - name: Deep public-type audit (supported-root strict, SD-3213e) + # Same gate as PR CI. One invocation prints the broad inventory + # AND runs the supported-root strict gate against the committed + # allowlist. Catches releases that bypass PR CI. + run: node tests/consumer-typecheck/deep-type-audit.mjs --strict-supported-root - name: Package shape gates # External package-shape linters (publint + attw) running against # the packed tarball. Same step as PR CI. run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) + - name: Public surface no-growth snapshots (SD-3176, SD-3212) # Same gate as PR CI. Catches releases that bypass PR CI. - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check + # Runs the unified entry point for all three snapshot families + # (super-editor-package, legacy, root). + run: node tests/consumer-typecheck/snapshot.mjs --all --check + + - name: Root classification closure gate (SD-3212 PR A1b) + # Same gate as PR CI. Catches releases that bypass PR CI. + run: node tests/consumer-typecheck/check-root-classification-closure.mjs # PR preview: publish with pr- dist-tag - name: Publish PR preview diff --git a/AGENTS.md b/AGENTS.md index 831c5438d0..32de3f1ba7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm run generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs +- `pnpm check:public-contract` - validate the published public type contract: wraps build + consumer typecheck matrix + strict supported-root audit. ~3 min. Scoped to the public type surface, not a replacement for `pnpm test` or `pnpm build`. Also the single command CI runs (with `--skip-build` after its own Build step). SD-3256 / SD-673. +- `pnpm report:public-contract` - print the public-contract tier metadata (supported / legacy / legacy-raw / asset / deprecated). Read-only. Source of truth: `packages/superdoc/scripts/type-surface.config.cjs` (`publicContract` export). SD-3256. ## Testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fbaece728..1badd75773 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,12 +228,16 @@ pnpm run format # Run Prettier ### Adding a public API -If your PR adds a new public export from `superdoc` (a new `@typedef` in `packages/superdoc/src/index.js`, a new value re-exported from a public subpath, or a new subpath in `package.json` `exports`), it must ship with a type test that exercises the new surface under strict mode. +If your PR adds a new public export from `superdoc` (a new entry in `packages/superdoc/src/public/index.ts`, a new value re-exported from a public subpath, or a new subpath in `package.json` `exports`), it must ship with a type test that exercises the new surface under strict mode. -The consumer matrix at `tests/consumer-typecheck/` is the regression net. Two automated checks enforce this on every PR: +The canonical root contract is `packages/superdoc/src/public/index.ts` (per the SD-3175 path-as-contract umbrella, finalized in SD-3212 PR C). Several automated gates enforce consistency on every PR: -- **`check-public-types.mjs`** -- the assertion list in `tests/consumer-typecheck/src/all-public-types.ts` is auto-derived from the `@typedef` block in `packages/superdoc/src/index.js`. Adding a new typedef without regenerating the list fails CI. Run `npm run check:types:write` from inside `tests/consumer-typecheck/` (or `node tests/consumer-typecheck/check-public-types.mjs --write` from the repo root) and commit the regenerated file. -- **`typecheck-matrix.mjs`** -- every typed public subpath in the RFC inventory has at least one matrix scenario. If you add a new subpath, add a fixture under `tests/consumer-typecheck/src/` and a corresponding entry in the matrix, and update the inventory in `docs/architecture/package-boundaries.md`. +- **`verify-public-facade-emit.cjs`** -- verifies the curated `src/public/**` facade matches the emitted `.d.ts` for symbol set, ESM/CJS parity, leak grep, and command-signature compatibility. Adding a new export updates the corresponding `expectedNames` array in this script in the same PR. +- **`snapshot.mjs --all --check`** -- unified entry point for the three snapshot families. The `root` family locks the root export inventory across the four `package.json#exports` sources (`types.import`, `types.require`, `import`, `require`); the `legacy` family locks `superdoc/*` subpath resolved exports; the `super-editor-package` family locks `@superdoc/super-editor`'s `package.json#exports` keys. Drift fails CI; run `snapshot.mjs --family --write` to regenerate one family after an intentional change. +- **`check-root-classification-closure.mjs`** -- enforces the dependency-closure rule: no `supported-root` or `legacy-root` export may reference an `internal-candidate` type in its declared public type. New exports require an entry in `tests/consumer-typecheck/snapshots/superdoc-root-classification.json`. +- **`typecheck-matrix.mjs`** -- every typed public subpath has at least one matrix scenario. If you add a new subpath, add a fixture under `tests/consumer-typecheck/src/` and a corresponding entry in the matrix, and update the inventory in `docs/architecture/package-boundaries.md`. +- **`check-all-public-types-fixture.mjs`** -- derives the expected type-only root export list from `superdoc-root-classification.json` (rows with `inDts && !inEsm && !inCjs`) and fails if `src/all-public-types.ts` is missing assertions or has stale ones. Runs before the matrix to catch fixture drift early. +- **`src/all-public-types.ts`** -- the fixture exercised by the SD-2842 matrix scenarios to catch any-collapses on customer-facing types. When you add a new type-only root export, add a corresponding `import { X } from 'superdoc';` plus `const _real_X: AssertNotAny = true;` line. The `check-all-public-types-fixture.mjs` gate fails CI if you forget. The point of these gates is to keep customer TypeScript builds working. A new export that ships without a type test can collapse to `any` (or fail to resolve) for consumers without the team noticing. diff --git a/TESTING.md b/TESTING.md index 9efda963bc..64d7d7afac 100644 --- a/TESTING.md +++ b/TESTING.md @@ -7,6 +7,7 @@ How to verify your changes before pushing. | What to verify | Command | Speed | CI Gate | |---|---|---|---| | Logic works? | `pnpm test` | ~30s | Hard | +| Document API smoke? | `pnpm test:document-api-smoke` | ~1 min | Hard | | Editing works? | `pnpm test:behavior` | ~3 min | Hard | | Layout regressed? | `pnpm test:layout` | ~10 min | Manual | | Visual pixel diff? | `pnpm test:visual` | ~5 min | Manual | @@ -23,6 +24,28 @@ pnpm --filter test # specific package Tests are co-located with source code as `feature.test.ts` next to `feature.ts`. Framework: Vitest. +## Document API Smoke + +SuperDoc keeps only low-detail Document API guardrails in this repo: + +```bash +pnpm test:document-api-smoke +``` + +That smoke suite checks representative namespace/method presence and a +small SDK open/read/mutate/save/reopen workflow. + +Additional conformance coverage may exist outside this repo in a separate +checkout. + +If you maintain a separate conformance checkout, run it from there: + +```bash +cd /path/to/conformance-repo +SUPERDOC_REPO=/path/to/superdoc3 pnpm run test:document-api-conformance:report +SUPERDOC_REPO=/path/to/superdoc3 pnpm run test:document-api-conformance +``` + ## Behavior Tests Test editing interactions through a real browser โ€” typing, formatting, tables, comments, tracked changes, clipboard, toolbar. @@ -110,9 +133,9 @@ The command automatically: Upload a `.docx` file to the shared test corpus (used by layout, visual, and behavior tests): ```bash -pnpm corpus:upload ~/Downloads/my-file.docx -# Prompts for: Linear issue ID, short description -# โ†’ uploads as rendering/sd-1741-paragraph-between-borders.docx +pnpm corpus:upload ./path/to/my-file.docx +# Prompts for: issue ID or short description +# -> uploads as rendering/paragraph-between-borders.docx ``` After uploading, pull it locally with `pnpm corpus:pull` so it's available for all test suites. diff --git a/apps/cli/scripts/README-sd-3214.md b/apps/cli/scripts/README-sd-3214.md new file mode 100644 index 0000000000..5aa91e30e4 --- /dev/null +++ b/apps/cli/scripts/README-sd-3214.md @@ -0,0 +1,124 @@ +# SD-3214 โ€” Manual End-to-End Reproduction + +Three scenarios that exercise the headless SDK's comment-sync pipeline through real Yjs primitives. Runs in one Node process; no Liveblocks/Hocuspocus required. + +## 1. Run the repro (fix on) + +From the worktree root: + +```bash +NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts +``` + +Expected output (with the fix applied): + +``` +=== Scenario 1: READ โ€” browser โ†’ agent metadata propagation === + agent.comments.list() returned 1 item(s): +{ + id: "", + text: "Please review this clause.", + creatorName: "Browser User", + creatorEmail: "browser@example.com", + createdTime: 1779..., + target: "present", +} + READ โœ“ โ€” metadata fully propagated + +=== Scenario 2: WRITE โ€” agent resolves comment, browser sees it === + agent.comments.patch({status:'resolved'}).success === true + { + commentId: "", + isDone: true, + resolvedTime: 1779..., + } + WRITE โœ“ โ€” resolve propagated to Y.Array + +=== Scenario 3: DELETE โ€” agent deletes, Y.Array shrinks === + agent.comments.delete().success === true + Y.Array length after delete: 0 + DELETE โœ“ โ€” Y.Array entry removed +``` + +## 2. See the bug (fix off) + +To confirm what the pre-fix state looks like, disable each fix individually. + +### Disable read-side (bridge.attachEditor) + +```bash +# Comment out the attachEditor wiring: +sed -i.bak "s|commentBridge?.attachEditor(editor as never);|// commentBridge?.attachEditor(editor as never);|" \ + apps/cli/src/lib/document.ts + +NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts +# Scenario 1 now prints: +# text: undefined, creatorName: undefined, creatorEmail: undefined, createdTime: undefined +# READ โœ— โ€” metadata missing (this is the SD-3214 read-side bug pre-fix) + +# Restore: +mv apps/cli/src/lib/document.ts.bak apps/cli/src/lib/document.ts +``` + +### Disable write-side (wrapper emits) + +```bash +# Comment out the three emits in comments-wrappers.ts: +git stash # save state first +git checkout HEAD~1 -- packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +# (this reverts the wrapper to the first commit, before the write-side fix) + +NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts +# Scenarios 2 and 3 now print: +# WRITE โœ— โ€” resolve did not reach Y.Array +# DELETE โœ— โ€” Y.Array still has the entry + +# Restore: +git checkout HEAD -- packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +git stash pop +``` + +## 3. What each scenario simulates + +### Scenario 1 โ€” READ direction + +Mirrors the customer's flow: a user authors a comment in the browser SuperDoc; an agent connects to the same Y.Doc and reads the comment via `editor.doc.comments.list()`. + +Both sessions are headless Editor instances sharing one in-memory Y.Doc. The "browser" session uses the user identity `Browser User `; the "agent" session uses `Headless Agent `. Yjs broadcasts changes between them through the natural CRDT mechanism โ€” no network required. + +Pass condition: all four metadata fields (`text`, `creatorName`, `creatorEmail`, `createdTime`) populate on the agent side. + +### Scenario 2 โ€” WRITE direction (resolve) + +The agent calls `editor.doc.comments.patch({ commentId, status: 'resolved' })`. The Y.Array entry should reflect `isDone: true` and a numeric `resolvedTime`, so other clients observing the Y.Doc see the resolution. + +Pass condition: `yEntry.isDone === true && typeof yEntry.resolvedTime === 'number'`. + +### Scenario 3 โ€” WRITE direction (delete) + +The agent calls `editor.doc.comments.delete({ commentId })`. The Y.Array entry should disappear. + +Pass condition: `ydoc.getArray('comments').toJSON().length === 0`. + +## 4. Extending to a real two-process setup + +If you want to validate against an actual collaboration provider (Liveblocks, Hocuspocus, custom websocket), the same code structure works โ€” replace `providerStub()` with a real provider returned by `@superdoc-dev/cli`'s `createCollaborationRuntime`, and run the "browser" half in the dev server (`pnpm dev`) and the "agent" half via the CLI binary connected to the same room. + +The in-memory shared Y.Doc here is the structural equivalent. If it passes, the network case will pass too โ€” Yjs's wire protocol is just the same CRDT updates delivered over a socket. + +## 5. Running the unit + integration suites + +For machine-readable validation: + +```bash +# CLI integration tests (10 cases for SD-3214) +pnpm --filter @superdoc-dev/cli test -- --run sd-3214 + +# super-editor unit + integration tests +pnpm --filter super-editor test -- --run comment-entity-store +pnpm --filter super-editor test -- --run comments-wrappers + +# Full suites (slower, for pre-merge confidence) +pnpm --filter @superdoc-dev/cli test # 1248 pass +pnpm --filter super-editor test # 13117 pass +``` diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 11ff45164f..c6b9db1aab 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -48,6 +48,21 @@ function classifySdkSurface(operationId: string): SdkSurface { return 'document'; } +/** + * Resolves the response envelope key for a doc-backed operation, failing closed + * on missing entries. Missing entries would otherwise be coerced to null and + * silently leak a `[undefined]: result` wrap from the CLI orchestrators. + */ +function resolveDocBackedEnvelopeKey(docApiId: string): string | null { + if (!Object.prototype.hasOwnProperty.call(RESPONSE_ENVELOPE_KEY, docApiId)) { + throw new Error( + `export-sdk-contract: doc-backed operation '${docApiId}' has no RESPONSE_ENVELOPE_KEY entry. ` + + `Add one in apps/cli/src/cli/operation-hints.ts before regenerating the contract.`, + ); + } + return RESPONSE_ENVELOPE_KEY[docApiId as keyof typeof RESPONSE_ENVELOPE_KEY]; +} + function buildParamSchema(param: CliOperationParamSpec): Record { let schema: Record; @@ -144,7 +159,10 @@ function buildSdkContract() { // Response envelope key โ€” tells SDKs which property to unwrap from the CLI response. // null means result is spread across top-level keys (no unwrapping needed). - responseEnvelopeKey: docApiId ? (RESPONSE_ENVELOPE_KEY[docApiId] ?? null) : null, + // Doc-backed ops must have an explicit entry in RESPONSE_ENVELOPE_KEY; missing entries + // would otherwise be coerced to null here and silently leak a `[undefined]: result` + // wrap from the CLI orchestrators. + responseEnvelopeKey: docApiId ? resolveDocBackedEnvelopeKey(docApiId) : null, // Transport plane params: metadata.params.map((p) => { diff --git a/apps/cli/scripts/repro-sd-3214.ts b/apps/cli/scripts/repro-sd-3214.ts new file mode 100644 index 0000000000..ada6a7ff74 --- /dev/null +++ b/apps/cli/scripts/repro-sd-3214.ts @@ -0,0 +1,223 @@ +/** + * SD-3214 end-to-end manual reproduction. + * + * Runs entirely in one Node process โ€” no Liveblocks/Hocuspocus needed. + * Two Editor instances share a single Y.Doc, so changes from one side + * propagate to the other through the exact same Yjs primitives a real + * browser + agent pair would use over the wire. + * + * USAGE (from the worktree root): + * + * NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts + * + * Three scenarios print PASS/FAIL lines so you can eyeball whether the + * fix is active. See the "Toggle the fix" section in the guide for how + * to compare before vs after. + */ + +import { Doc as YDoc } from 'yjs'; +import { openDocument } from '../src/lib/document'; + +const io = { + stdout: () => {}, + stderr: () => {}, + readStdinBytes: async () => new Uint8Array(), + now: () => Date.now(), +}; + +function providerStub() { + const noop = () => {}; + return { + synced: true, + awareness: { + on: noop, + off: noop, + getStates: () => new Map(), + setLocalState: noop, + setLocalStateField: noop, + }, + on: noop, + off: noop, + connect: noop, + disconnect: noop, + destroy: noop, + }; +} + +// --------------------------------------------------------------------------- +// Scenario 1: READ โ€” browser authors, agent reads, metadata propagates +// --------------------------------------------------------------------------- + +async function scenarioReadSide() { + console.log('\n=== Scenario 1: READ โ€” browser โ†’ agent metadata propagation ==='); + const ydoc = new YDoc(); + + const browser = await openDocument(undefined, io, { + documentId: 'sd-3214-readside', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + + browser.editor.doc.create.paragraph({ + at: { kind: 'documentEnd' }, + text: 'A clause about indemnification.', + }); + const block = browser.editor.doc.query.match({ + select: { type: 'text', pattern: 'indemnification' }, + require: 'first', + }).items[0]!.blocks[0]!; + browser.editor.doc.comments.create({ + target: { kind: 'text', blockId: block.blockId, range: block.range } as never, + text: 'Please review this clause.', + }); + + const agent = await openDocument(undefined, io, { + documentId: 'sd-3214-readside', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: false, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + const list = agent.editor.doc.comments.list(); + console.log(` agent.comments.list() returned ${list.items.length} item(s):`); + for (const item of list.items) { + console.log({ + id: item.id, + text: (item as { text?: string }).text, + creatorName: (item as { creatorName?: string }).creatorName, + creatorEmail: (item as { creatorEmail?: string }).creatorEmail, + createdTime: (item as { createdTime?: number }).createdTime, + target: (item as { target?: unknown }).target ? 'present' : 'absent', + }); + } + + const item = list.items[0] as { text?: string; creatorName?: string; createdTime?: number } | undefined; + if (item?.text && item?.creatorName && item?.createdTime) { + console.log(' READ โœ“ โ€” metadata fully propagated'); + } else { + console.log(' READ โœ— โ€” metadata missing (this is the SD-3214 read-side bug pre-fix)'); + } + + browser.dispose(); + agent.dispose(); +} + +// --------------------------------------------------------------------------- +// Scenario 2: WRITE / RESOLVE โ€” agent resolves, Y.Array reflects it +// --------------------------------------------------------------------------- + +async function scenarioWriteSideResolve() { + console.log('\n=== Scenario 2: WRITE โ€” agent resolves comment, browser sees it ==='); + const ydoc = new YDoc(); + + const browser = await openDocument(undefined, io, { + documentId: 'sd-3214-writeside', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + browser.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to be resolved.' }); + const block = browser.editor.doc.query.match({ + select: { type: 'text', pattern: 'resolved' }, + require: 'first', + }).items[0]!.blocks[0]!; + browser.editor.doc.comments.create({ + target: { kind: 'text', blockId: block.blockId, range: block.range } as never, + text: 'Resolve me.', + }); + const targetId = (ydoc.getArray('comments').toJSON() as Array>)[0]!.commentId as string; + + const agent = await openDocument(undefined, io, { + documentId: 'sd-3214-writeside', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: false, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + const patch = agent.editor.doc.comments.patch({ commentId: targetId, status: 'resolved' }); + console.log(` agent.comments.patch({status:'resolved'}).success === ${patch.success}`); + + const yEntry = (ydoc.getArray('comments').toJSON() as Array>)[0]!; + console.log({ + commentId: yEntry.commentId, + isDone: yEntry.isDone, + resolvedTime: yEntry.resolvedTime, + }); + + if (yEntry.isDone === true && typeof yEntry.resolvedTime === 'number') { + console.log(' WRITE โœ“ โ€” resolve propagated to Y.Array'); + } else { + console.log(' WRITE โœ— โ€” resolve did not reach Y.Array (this is the write-side gap pre-fix)'); + } + + browser.dispose(); + agent.dispose(); +} + +// --------------------------------------------------------------------------- +// Scenario 3: DELETE โ€” agent deletes, Y.Array entry disappears +// --------------------------------------------------------------------------- + +async function scenarioDelete() { + console.log('\n=== Scenario 3: DELETE โ€” agent deletes, Y.Array shrinks ==='); + const ydoc = new YDoc(); + + const browser = await openDocument(undefined, io, { + documentId: 'sd-3214-delete', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + browser.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to delete.' }); + const block = browser.editor.doc.query.match({ + select: { type: 'text', pattern: 'delete' }, + require: 'first', + }).items[0]!.blocks[0]!; + browser.editor.doc.comments.create({ + target: { kind: 'text', blockId: block.blockId, range: block.range } as never, + text: 'I will be deleted.', + }); + const targetId = (ydoc.getArray('comments').toJSON() as Array>)[0]!.commentId as string; + + const agent = await openDocument(undefined, io, { + documentId: 'sd-3214-delete', + ydoc, + collaborationProvider: providerStub() as never, + isNewFile: false, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + const del = agent.editor.doc.comments.delete({ commentId: targetId }); + console.log(` agent.comments.delete().success === ${del.success}`); + + const yArr = ydoc.getArray('comments').toJSON() as Array>; + console.log(` Y.Array length after delete: ${yArr.length}`); + + if (yArr.length === 0) { + console.log(' DELETE โœ“ โ€” Y.Array entry removed'); + } else { + console.log(' DELETE โœ— โ€” Y.Array still has the entry (this is the write-side gap pre-fix)'); + } + + browser.dispose(); + agent.dispose(); +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +try { + await scenarioReadSide(); + await scenarioWriteSideResolve(); + await scenarioDelete(); + console.log('\nDone.'); + process.exit(0); +} catch (err) { + console.error('Repro crashed:', err); + process.exit(1); +} diff --git a/apps/cli/src/__tests__/contract-response-conformance.test.ts b/apps/cli/src/__tests__/contract-response-conformance.test.ts index 65b3220984..8101e3fb8c 100644 --- a/apps/cli/src/__tests__/contract-response-conformance.test.ts +++ b/apps/cli/src/__tests__/contract-response-conformance.test.ts @@ -50,11 +50,12 @@ describe('contract response conformance', () => { const success = envelope as SuccessEnvelope; validateOperationResponseData(scenario.operationId, success.data, commandKey); - // Regression guard: history operations must serialize payload under `result`, - // never under an "undefined" key from missing envelope metadata. - if (scenario.operationId.startsWith('doc.history.')) { + // Regression guard: no successful CLI response may serialize its payload + // under the property name "undefined" โ€” which is what JS does when the + // orchestrator reads an undefined envelope key as a dynamic property + // (`{ [undefined]: result }` โ†’ `{ "undefined": ... }`). + if (success.data && typeof success.data === 'object') { const data = success.data as Record; - expect(Object.prototype.hasOwnProperty.call(data, 'result')).toBe(true); expect(Object.prototype.hasOwnProperty.call(data, 'undefined')).toBe(false); } }); diff --git a/apps/cli/src/__tests__/host.test.ts b/apps/cli/src/__tests__/host.test.ts index f2f014e25c..b312881635 100644 --- a/apps/cli/src/__tests__/host.test.ts +++ b/apps/cli/src/__tests__/host.test.ts @@ -47,14 +47,17 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s }); } -function launchHost(stateDir: string): { +function launchHost( + stateDir: string, + extraArgs: string[] = [], +): { child: ChildProcessWithoutNullStreams; request(method: string, params?: unknown): Promise; sendRaw(frame: string): void; nextMessage(): Promise; shutdown(): Promise; } { - const child = spawn('bun', [CLI_BIN, 'host', '--stdio'], { + const child = spawn('bun', [CLI_BIN, 'host', '--stdio', ...extraArgs], { cwd: REPO_ROOT, env: { ...process.env, @@ -389,4 +392,62 @@ describe('CLI host mode', () => { }, HOST_TEST_TIMEOUT_MS, ); + + test( + 'honors --request-timeout-ms for a real cli.invoke ceiling', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); + + const sourceDoc = await resolveSourceDocFixture(); + const docCopy = path.join(stateDir, 'doc.docx'); + await copyFile(sourceDoc, docCopy); + + // 1ms is well below any real cli.invoke wall time, so the host's + // settleWithTimeout must fire and return a RequestTimeout error + // carrying the configured value. + const host = launchHost(stateDir, ['--request-timeout-ms', '1']); + + const response = await host.request('cli.invoke', { + argv: ['open', docCopy], + stdinBase64: '', + }); + + expect(response.error?.code).toBe(-32011); + const errorData = response.error?.data as { timeoutMs?: number }; + expect(errorData.timeoutMs).toBe(1); + + await host.shutdown(); + }, + HOST_TEST_TIMEOUT_MS, + ); + + test( + 'rejects --request-timeout-ms with a non-numeric value', + async () => { + const child = spawn('bun', [CLI_BIN, 'host', '--stdio', '--request-timeout-ms', 'not-a-number'], { + cwd: REPO_ROOT, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderrBuffer = ''; + child.stderr.on('data', (chunk) => { + stderrBuffer += String(chunk); + }); + + const exitCode = await withTimeout( + new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? -1)); + }), + 5_000, + 'Timed out waiting for host to exit on invalid --request-timeout-ms.', + ); + + expect(exitCode).not.toBe(0); + expect(stderrBuffer).toContain('--request-timeout-ms'); + expect(stderrBuffer).toContain('positive finite number'); + }, + HOST_TEST_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/__tests__/lib/_collab-password-worker.ts b/apps/cli/src/__tests__/lib/_collab-password-worker.ts index 28d30d11e5..83da2eb063 100644 --- a/apps/cli/src/__tests__/lib/_collab-password-worker.ts +++ b/apps/cli/src/__tests__/lib/_collab-password-worker.ts @@ -32,6 +32,8 @@ mock.module('superdoc/super-editor', () => ({ getDocumentApiAdapters: () => ({}), markdownToPmDoc: () => null, initPartsRuntime: () => ({ dispose: () => {} }), + // SD-3214: bridge imports this to feed Y.Array entries into the store. + syncCommentEntitiesFromCollaboration: () => new Set(), })); mock.module('happy-dom', () => ({ diff --git a/apps/cli/src/__tests__/operation-hints-coverage.test.ts b/apps/cli/src/__tests__/operation-hints-coverage.test.ts new file mode 100644 index 0000000000..eacc7915f6 --- /dev/null +++ b/apps/cli/src/__tests__/operation-hints-coverage.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { CLI_DOC_OPERATIONS } from '../cli/operation-set'; +import { OPERATION_FAMILY, OUTPUT_FORMAT, RESPONSE_ENVELOPE_KEY, SUCCESS_VERB } from '../cli/operation-hints'; + +// The four hint tables are typed as `Record`, but +// apps/cli does not run `tsc --noEmit` in CI, so the type-level exhaustiveness +// check is not enforced. This runtime test gives us the same protection under +// `pnpm test` โ€” if a new doc-backed operation lands without a matching hint +// entry, mutation/read orchestrators would silently serialize its payload under +// the property name "undefined" (and the SDK exporter would coerce the missing +// hint into a null envelope key, leaking the wrap to SDK callers). +describe('operation hint coverage', () => { + const hintTables = [ + { name: 'RESPONSE_ENVELOPE_KEY', table: RESPONSE_ENVELOPE_KEY as Record }, + { name: 'OPERATION_FAMILY', table: OPERATION_FAMILY as Record }, + { name: 'SUCCESS_VERB', table: SUCCESS_VERB as Record }, + { name: 'OUTPUT_FORMAT', table: OUTPUT_FORMAT as Record }, + ]; + + for (const { name, table } of hintTables) { + test(`${name} has an own entry for every CLI_DOC_OPERATIONS id`, () => { + const missing = CLI_DOC_OPERATIONS.filter( + (operationId) => !Object.prototype.hasOwnProperty.call(table, operationId), + ); + expect(missing).toEqual([]); + }); + } +}); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 5ae3f696c9..59c07fbb73 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -214,6 +214,215 @@ export const SUCCESS_VERB: Record = { 'diff.capture': 'captured snapshot', 'diff.compare': 'compared documents', 'diff.apply': 'applied diff', + + // Every doc-api operation must have a success verb. + // Used only by `--pretty` output; JSON envelope unaffected. + 'authorities.configure': 'configured', + 'authorities.entries.get': 'retrieved item', + 'authorities.entries.insert': 'inserted item', + 'authorities.entries.list': 'listed items', + 'authorities.entries.remove': 'removed item', + 'authorities.entries.update': 'updated item', + 'authorities.get': 'retrieved item', + 'authorities.insert': 'inserted item', + 'authorities.list': 'listed items', + 'authorities.rebuild': 'rebuilt', + 'authorities.remove': 'removed item', + 'bookmarks.get': 'retrieved item', + 'bookmarks.insert': 'inserted item', + 'bookmarks.list': 'listed items', + 'bookmarks.remove': 'removed item', + 'bookmarks.rename': 'renamed item', + 'captions.configure': 'configured', + 'captions.get': 'retrieved item', + 'captions.insert': 'inserted item', + 'captions.list': 'listed items', + 'captions.remove': 'removed item', + 'captions.update': 'updated item', + 'citations.bibliography.configure': 'configured', + 'citations.bibliography.get': 'retrieved item', + 'citations.bibliography.insert': 'inserted item', + 'citations.bibliography.rebuild': 'rebuilt', + 'citations.bibliography.remove': 'removed item', + 'citations.get': 'retrieved item', + 'citations.insert': 'inserted item', + 'citations.list': 'listed items', + 'citations.remove': 'removed item', + 'citations.sources.get': 'retrieved item', + 'citations.sources.insert': 'inserted item', + 'citations.sources.list': 'listed items', + 'citations.sources.remove': 'removed item', + 'citations.sources.update': 'updated item', + 'citations.update': 'updated item', + 'contentControls.appendContent': 'appended content', + 'contentControls.checkbox.getState': 'retrieved state', + 'contentControls.checkbox.setState': 'updated state', + 'contentControls.checkbox.setSymbolPair': 'updated symbol pair', + 'contentControls.checkbox.toggle': 'toggled checkbox', + 'contentControls.choiceList.getItems': 'retrieved items', + 'contentControls.choiceList.setItems': 'updated items', + 'contentControls.choiceList.setSelected': 'updated selected', + 'contentControls.clearBinding': 'cleared binding', + 'contentControls.clearContent': 'cleared content', + 'contentControls.copy': 'copied item', + 'contentControls.date.clearValue': 'cleared value', + 'contentControls.date.setCalendar': 'updated calendar', + 'contentControls.date.setDisplayFormat': 'updated display format', + 'contentControls.date.setDisplayLocale': 'updated display locale', + 'contentControls.date.setStorageFormat': 'updated storage format', + 'contentControls.date.setValue': 'updated value', + 'contentControls.delete': 'deleted item', + 'contentControls.get': 'retrieved item', + 'contentControls.getBinding': 'retrieved binding', + 'contentControls.getContent': 'retrieved content', + 'contentControls.getParent': 'retrieved parent', + 'contentControls.getRawProperties': 'retrieved raw properties', + 'contentControls.group.ungroup': 'ungrouped', + 'contentControls.group.wrap': 'wrapped group', + 'contentControls.insertAfter': 'inserted after', + 'contentControls.insertBefore': 'inserted before', + 'contentControls.list': 'listed items', + 'contentControls.listChildren': 'listed children', + 'contentControls.listInRange': 'listed in range', + 'contentControls.move': 'moved item', + 'contentControls.normalizeTagPayload': 'normalized tag payload', + 'contentControls.normalizeWordCompatibility': 'normalized word compatibility', + 'contentControls.patch': 'patched item', + 'contentControls.patchRawProperties': 'patched raw properties', + 'contentControls.prependContent': 'prepended content', + 'contentControls.repeatingSection.cloneItem': 'cloned item', + 'contentControls.repeatingSection.deleteItem': 'deleted item', + 'contentControls.repeatingSection.insertItemAfter': 'inserted item after', + 'contentControls.repeatingSection.insertItemBefore': 'inserted item before', + 'contentControls.repeatingSection.listItems': 'listed items', + 'contentControls.repeatingSection.setAllowInsertDelete': 'updated allow insert delete', + 'contentControls.replaceContent': 'replaced content', + 'contentControls.selectByTag': 'selected by tag', + 'contentControls.selectByTitle': 'selected by title', + 'contentControls.setBinding': 'updated binding', + 'contentControls.setLockMode': 'updated lock mode', + 'contentControls.setType': 'updated type', + 'contentControls.text.clearValue': 'cleared value', + 'contentControls.text.setMultiline': 'updated multiline', + 'contentControls.text.setValue': 'updated value', + 'contentControls.unwrap': 'unwrapped', + 'contentControls.validateWordCompatibility': 'validated word compatibility', + 'contentControls.wrap': 'wrapped', + 'create.contentControl': 'created content control', + 'create.sectionBreak': 'created section break', + 'crossRefs.get': 'retrieved item', + 'crossRefs.insert': 'inserted item', + 'crossRefs.list': 'listed items', + 'crossRefs.rebuild': 'rebuilt', + 'crossRefs.remove': 'removed item', + 'customXml.parts.create': 'created item', + 'customXml.parts.get': 'retrieved item', + 'customXml.parts.list': 'listed items', + 'customXml.parts.patch': 'patched item', + 'customXml.parts.remove': 'removed item', + 'fields.get': 'retrieved item', + 'fields.insert': 'inserted item', + 'fields.list': 'listed items', + 'fields.rebuild': 'rebuilt', + 'fields.remove': 'removed item', + 'footnotes.configure': 'configured', + 'footnotes.get': 'retrieved item', + 'footnotes.insert': 'inserted item', + 'footnotes.list': 'listed items', + 'footnotes.remove': 'removed item', + 'footnotes.update': 'updated item', + 'format.paragraph.clearDirection': 'cleared direction', + 'format.paragraph.setDirection': 'updated direction', + 'headerFooters.get': 'retrieved item', + 'headerFooters.list': 'listed items', + 'headerFooters.parts.create': 'created item', + 'headerFooters.parts.delete': 'deleted item', + 'headerFooters.parts.list': 'listed items', + 'headerFooters.refs.clear': 'cleared ref', + 'headerFooters.refs.set': 'updated ref', + 'headerFooters.refs.setLinkedToPrevious': 'updated linked to previous', + 'headerFooters.resolve': 'resolved', + 'hyperlinks.get': 'retrieved item', + 'hyperlinks.insert': 'inserted item', + 'hyperlinks.list': 'listed items', + 'hyperlinks.patch': 'patched item', + 'hyperlinks.remove': 'removed item', + 'hyperlinks.wrap': 'wrapped', + 'images.crop': 'cropped image', + 'images.flip': 'flipped image', + 'images.insertCaption': 'inserted caption', + 'images.removeCaption': 'removed caption', + 'images.replaceSource': 'replaced source', + 'images.resetCrop': 'reset crop', + 'images.rotate': 'rotated image', + 'images.scale': 'scaled image', + 'images.setAltText': 'updated alt text', + 'images.setDecorative': 'updated decorative', + 'images.setHyperlink': 'updated hyperlink', + 'images.setLockAspectRatio': 'updated lock aspect ratio', + 'images.setName': 'updated name', + 'images.updateCaption': 'updated caption', + 'index.configure': 'configured', + 'index.entries.get': 'retrieved item', + 'index.entries.insert': 'inserted item', + 'index.entries.list': 'listed items', + 'index.entries.remove': 'removed item', + 'index.entries.update': 'updated item', + 'index.get': 'retrieved item', + 'index.insert': 'inserted item', + 'index.list': 'listed items', + 'index.rebuild': 'rebuilt', + 'index.remove': 'removed item', + 'lists.applyStyle': 'applied style', + 'lists.delete': 'deleted item', + 'lists.getStyle': 'retrieved style', + 'lists.merge': 'merged lists', + 'lists.restartAt': 'restarted numbering', + 'lists.setLevelLayout': 'updated level layout', + 'lists.setLevelNumberStyle': 'updated level number style', + 'lists.setLevelStart': 'updated level start', + 'lists.setLevelText': 'updated level text', + 'lists.setType': 'updated type', + 'lists.split': 'split list', + 'metadata.attach': 'attached metadata', + 'metadata.get': 'retrieved item', + 'metadata.list': 'listed items', + 'metadata.remove': 'removed item', + 'metadata.resolve': 'resolved metadata', + 'metadata.update': 'updated item', + 'permissionRanges.create': 'created item', + 'permissionRanges.get': 'retrieved item', + 'permissionRanges.list': 'listed items', + 'permissionRanges.remove': 'removed item', + 'permissionRanges.updatePrincipal': 'updated principal', + 'protection.clearEditingRestriction': 'cleared editing restriction', + 'protection.get': 'retrieved item', + 'protection.setEditingRestriction': 'updated editing restriction', + 'ranges.resolve': 'resolved range', + 'sections.clearHeaderFooterRef': 'cleared header footer ref', + 'sections.clearPageBorders': 'cleared page borders', + 'sections.get': 'retrieved item', + 'sections.list': 'listed items', + 'sections.setBreakType': 'updated break type', + 'sections.setColumns': 'updated columns', + 'sections.setHeaderFooterMargins': 'updated header footer margins', + 'sections.setHeaderFooterRef': 'updated header footer ref', + 'sections.setLineNumbering': 'updated line numbering', + 'sections.setLinkToPrevious': 'updated link to previous', + 'sections.setOddEvenHeadersFooters': 'updated odd even headers footers', + 'sections.setPageBorders': 'updated page borders', + 'sections.setPageMargins': 'updated page margins', + 'sections.setPageNumbering': 'updated page numbering', + 'sections.setPageSetup': 'updated page setup', + 'sections.setSectionDirection': 'updated section direction', + 'sections.setTitlePage': 'updated title page', + 'sections.setVerticalAlign': 'updated vertical align', + 'selection.current': 'retrieved current selection', + 'tables.applyPreset': 'applied table preset', + 'tables.applyStyle': 'applied table style', + 'tables.setBorders': 'updated borders', + 'tables.setCellText': 'updated cell text', + 'tables.setTableOptions': 'updated table options', }; // --------------------------------------------------------------------------- @@ -390,6 +599,216 @@ export const OUTPUT_FORMAT: Record = { 'diff.capture': 'diffSnapshot', 'diff.compare': 'diffPayload', 'diff.apply': 'diffApplyResult', + + // Every doc-api operation must declare an output format. + // 'plain' falls back to the default Revision/verb pretty output (FORMAT_DISPATCH + // has no entry for 'plain' so formatOutput returns null). + 'authorities.configure': 'plain', + 'authorities.entries.get': 'plain', + 'authorities.entries.insert': 'plain', + 'authorities.entries.list': 'plain', + 'authorities.entries.remove': 'plain', + 'authorities.entries.update': 'plain', + 'authorities.get': 'plain', + 'authorities.insert': 'plain', + 'authorities.list': 'plain', + 'authorities.rebuild': 'plain', + 'authorities.remove': 'plain', + 'bookmarks.get': 'plain', + 'bookmarks.insert': 'plain', + 'bookmarks.list': 'plain', + 'bookmarks.remove': 'plain', + 'bookmarks.rename': 'plain', + 'captions.configure': 'plain', + 'captions.get': 'plain', + 'captions.insert': 'plain', + 'captions.list': 'plain', + 'captions.remove': 'plain', + 'captions.update': 'plain', + 'citations.bibliography.configure': 'plain', + 'citations.bibliography.get': 'plain', + 'citations.bibliography.insert': 'plain', + 'citations.bibliography.rebuild': 'plain', + 'citations.bibliography.remove': 'plain', + 'citations.get': 'plain', + 'citations.insert': 'plain', + 'citations.list': 'plain', + 'citations.remove': 'plain', + 'citations.sources.get': 'plain', + 'citations.sources.insert': 'plain', + 'citations.sources.list': 'plain', + 'citations.sources.remove': 'plain', + 'citations.sources.update': 'plain', + 'citations.update': 'plain', + 'contentControls.appendContent': 'plain', + 'contentControls.checkbox.getState': 'plain', + 'contentControls.checkbox.setState': 'plain', + 'contentControls.checkbox.setSymbolPair': 'plain', + 'contentControls.checkbox.toggle': 'plain', + 'contentControls.choiceList.getItems': 'plain', + 'contentControls.choiceList.setItems': 'plain', + 'contentControls.choiceList.setSelected': 'plain', + 'contentControls.clearBinding': 'plain', + 'contentControls.clearContent': 'plain', + 'contentControls.copy': 'plain', + 'contentControls.date.clearValue': 'plain', + 'contentControls.date.setCalendar': 'plain', + 'contentControls.date.setDisplayFormat': 'plain', + 'contentControls.date.setDisplayLocale': 'plain', + 'contentControls.date.setStorageFormat': 'plain', + 'contentControls.date.setValue': 'plain', + 'contentControls.delete': 'plain', + 'contentControls.get': 'plain', + 'contentControls.getBinding': 'plain', + 'contentControls.getContent': 'plain', + 'contentControls.getParent': 'plain', + 'contentControls.getRawProperties': 'plain', + 'contentControls.group.ungroup': 'plain', + 'contentControls.group.wrap': 'plain', + 'contentControls.insertAfter': 'plain', + 'contentControls.insertBefore': 'plain', + 'contentControls.list': 'plain', + 'contentControls.listChildren': 'plain', + 'contentControls.listInRange': 'plain', + 'contentControls.move': 'plain', + 'contentControls.normalizeTagPayload': 'plain', + 'contentControls.normalizeWordCompatibility': 'plain', + 'contentControls.patch': 'plain', + 'contentControls.patchRawProperties': 'plain', + 'contentControls.prependContent': 'plain', + 'contentControls.repeatingSection.cloneItem': 'plain', + 'contentControls.repeatingSection.deleteItem': 'plain', + 'contentControls.repeatingSection.insertItemAfter': 'plain', + 'contentControls.repeatingSection.insertItemBefore': 'plain', + 'contentControls.repeatingSection.listItems': 'plain', + 'contentControls.repeatingSection.setAllowInsertDelete': 'plain', + 'contentControls.replaceContent': 'plain', + 'contentControls.selectByTag': 'plain', + 'contentControls.selectByTitle': 'plain', + 'contentControls.setBinding': 'plain', + 'contentControls.setLockMode': 'plain', + 'contentControls.setType': 'plain', + 'contentControls.text.clearValue': 'plain', + 'contentControls.text.setMultiline': 'plain', + 'contentControls.text.setValue': 'plain', + 'contentControls.unwrap': 'plain', + 'contentControls.validateWordCompatibility': 'plain', + 'contentControls.wrap': 'plain', + 'create.contentControl': 'plain', + 'create.sectionBreak': 'plain', + 'crossRefs.get': 'plain', + 'crossRefs.insert': 'plain', + 'crossRefs.list': 'plain', + 'crossRefs.rebuild': 'plain', + 'crossRefs.remove': 'plain', + 'customXml.parts.create': 'plain', + 'customXml.parts.get': 'plain', + 'customXml.parts.list': 'plain', + 'customXml.parts.patch': 'plain', + 'customXml.parts.remove': 'plain', + 'fields.get': 'plain', + 'fields.insert': 'plain', + 'fields.list': 'plain', + 'fields.rebuild': 'plain', + 'fields.remove': 'plain', + 'footnotes.configure': 'plain', + 'footnotes.get': 'plain', + 'footnotes.insert': 'plain', + 'footnotes.list': 'plain', + 'footnotes.remove': 'plain', + 'footnotes.update': 'plain', + 'format.paragraph.clearDirection': 'plain', + 'format.paragraph.setDirection': 'plain', + 'headerFooters.get': 'plain', + 'headerFooters.list': 'plain', + 'headerFooters.parts.create': 'plain', + 'headerFooters.parts.delete': 'plain', + 'headerFooters.parts.list': 'plain', + 'headerFooters.refs.clear': 'plain', + 'headerFooters.refs.set': 'plain', + 'headerFooters.refs.setLinkedToPrevious': 'plain', + 'headerFooters.resolve': 'plain', + 'hyperlinks.get': 'plain', + 'hyperlinks.insert': 'plain', + 'hyperlinks.list': 'plain', + 'hyperlinks.patch': 'plain', + 'hyperlinks.remove': 'plain', + 'hyperlinks.wrap': 'plain', + 'images.crop': 'plain', + 'images.flip': 'plain', + 'images.insertCaption': 'plain', + 'images.removeCaption': 'plain', + 'images.replaceSource': 'plain', + 'images.resetCrop': 'plain', + 'images.rotate': 'plain', + 'images.scale': 'plain', + 'images.setAltText': 'plain', + 'images.setDecorative': 'plain', + 'images.setHyperlink': 'plain', + 'images.setLockAspectRatio': 'plain', + 'images.setName': 'plain', + 'images.updateCaption': 'plain', + 'index.configure': 'plain', + 'index.entries.get': 'plain', + 'index.entries.insert': 'plain', + 'index.entries.list': 'plain', + 'index.entries.remove': 'plain', + 'index.entries.update': 'plain', + 'index.get': 'plain', + 'index.insert': 'plain', + 'index.list': 'plain', + 'index.rebuild': 'plain', + 'index.remove': 'plain', + 'lists.applyStyle': 'plain', + 'lists.delete': 'plain', + 'lists.getStyle': 'plain', + 'lists.merge': 'plain', + 'lists.restartAt': 'plain', + 'lists.setLevelLayout': 'plain', + 'lists.setLevelNumberStyle': 'plain', + 'lists.setLevelStart': 'plain', + 'lists.setLevelText': 'plain', + 'lists.setType': 'plain', + 'lists.split': 'plain', + 'metadata.attach': 'plain', + 'metadata.get': 'plain', + 'metadata.list': 'plain', + 'metadata.remove': 'plain', + 'metadata.resolve': 'plain', + 'metadata.update': 'plain', + 'permissionRanges.create': 'plain', + 'permissionRanges.get': 'plain', + 'permissionRanges.list': 'plain', + 'permissionRanges.remove': 'plain', + 'permissionRanges.updatePrincipal': 'plain', + 'protection.clearEditingRestriction': 'plain', + 'protection.get': 'plain', + 'protection.setEditingRestriction': 'plain', + 'ranges.resolve': 'plain', + 'sections.clearHeaderFooterRef': 'plain', + 'sections.clearPageBorders': 'plain', + 'sections.get': 'plain', + 'sections.list': 'plain', + 'sections.setBreakType': 'plain', + 'sections.setColumns': 'plain', + 'sections.setHeaderFooterMargins': 'plain', + 'sections.setHeaderFooterRef': 'plain', + 'sections.setLineNumbering': 'plain', + 'sections.setLinkToPrevious': 'plain', + 'sections.setOddEvenHeadersFooters': 'plain', + 'sections.setPageBorders': 'plain', + 'sections.setPageMargins': 'plain', + 'sections.setPageNumbering': 'plain', + 'sections.setPageSetup': 'plain', + 'sections.setSectionDirection': 'plain', + 'sections.setTitlePage': 'plain', + 'sections.setVerticalAlign': 'plain', + 'selection.current': 'plain', + 'tables.applyPreset': 'plain', + 'tables.applyStyle': 'plain', + 'tables.setBorders': 'plain', + 'tables.setCellText': 'plain', + 'tables.setTableOptions': 'plain', }; // --------------------------------------------------------------------------- @@ -558,6 +977,206 @@ export const RESPONSE_ENVELOPE_KEY: Record 'diff.capture': 'snapshot', 'diff.compare': 'diff', 'diff.apply': 'result', + + // Every doc-api operation must declare an envelope key so missing entries + // can't degrade to `[undefined]: result` at runtime. + 'authorities.configure': 'result', + 'authorities.entries.get': 'result', + 'authorities.entries.insert': 'result', + 'authorities.entries.list': 'result', + 'authorities.entries.remove': 'result', + 'authorities.entries.update': 'result', + 'authorities.get': 'result', + 'authorities.insert': 'result', + 'authorities.list': 'result', + 'authorities.rebuild': 'result', + 'authorities.remove': 'result', + 'bookmarks.get': 'result', + 'bookmarks.insert': 'result', + 'bookmarks.list': 'result', + 'bookmarks.remove': 'result', + 'bookmarks.rename': 'result', + 'captions.configure': 'result', + 'captions.get': 'result', + 'captions.insert': 'result', + 'captions.list': 'result', + 'captions.remove': 'result', + 'captions.update': 'result', + 'citations.bibliography.configure': 'result', + 'citations.bibliography.get': 'result', + 'citations.bibliography.insert': 'result', + 'citations.bibliography.rebuild': 'result', + 'citations.bibliography.remove': 'result', + 'citations.get': 'result', + 'citations.insert': 'result', + 'citations.list': 'result', + 'citations.remove': 'result', + 'citations.sources.get': 'result', + 'citations.sources.insert': 'result', + 'citations.sources.list': 'result', + 'citations.sources.remove': 'result', + 'citations.sources.update': 'result', + 'citations.update': 'result', + 'contentControls.appendContent': 'result', + 'contentControls.checkbox.getState': 'result', + 'contentControls.checkbox.setState': 'result', + 'contentControls.checkbox.setSymbolPair': 'result', + 'contentControls.checkbox.toggle': 'result', + 'contentControls.choiceList.getItems': 'result', + 'contentControls.choiceList.setItems': 'result', + 'contentControls.choiceList.setSelected': 'result', + 'contentControls.clearBinding': 'result', + 'contentControls.clearContent': 'result', + 'contentControls.copy': 'result', + 'contentControls.date.clearValue': 'result', + 'contentControls.date.setCalendar': 'result', + 'contentControls.date.setDisplayFormat': 'result', + 'contentControls.date.setDisplayLocale': 'result', + 'contentControls.date.setStorageFormat': 'result', + 'contentControls.date.setValue': 'result', + 'contentControls.delete': 'result', + 'contentControls.get': 'result', + 'contentControls.getBinding': 'result', + 'contentControls.getContent': 'result', + 'contentControls.getParent': 'result', + 'contentControls.getRawProperties': 'result', + 'contentControls.group.ungroup': 'result', + 'contentControls.group.wrap': 'result', + 'contentControls.insertAfter': 'result', + 'contentControls.insertBefore': 'result', + 'contentControls.list': 'result', + 'contentControls.listChildren': 'result', + 'contentControls.listInRange': 'result', + 'contentControls.move': 'result', + 'contentControls.normalizeTagPayload': 'result', + 'contentControls.normalizeWordCompatibility': 'result', + 'contentControls.patch': 'result', + 'contentControls.patchRawProperties': 'result', + 'contentControls.prependContent': 'result', + 'contentControls.repeatingSection.cloneItem': 'result', + 'contentControls.repeatingSection.deleteItem': 'result', + 'contentControls.repeatingSection.insertItemAfter': 'result', + 'contentControls.repeatingSection.insertItemBefore': 'result', + 'contentControls.repeatingSection.listItems': 'result', + 'contentControls.repeatingSection.setAllowInsertDelete': 'result', + 'contentControls.replaceContent': 'result', + 'contentControls.selectByTag': 'result', + 'contentControls.selectByTitle': 'result', + 'contentControls.setBinding': 'result', + 'contentControls.setLockMode': 'result', + 'contentControls.setType': 'result', + 'contentControls.text.clearValue': 'result', + 'contentControls.text.setMultiline': 'result', + 'contentControls.text.setValue': 'result', + 'contentControls.unwrap': 'result', + 'contentControls.validateWordCompatibility': 'result', + 'contentControls.wrap': 'result', + 'create.contentControl': 'result', + 'create.sectionBreak': 'result', + 'crossRefs.get': 'result', + 'crossRefs.insert': 'result', + 'crossRefs.list': 'result', + 'crossRefs.rebuild': 'result', + 'crossRefs.remove': 'result', + 'customXml.parts.create': 'result', + 'customXml.parts.get': 'result', + 'customXml.parts.list': 'result', + 'customXml.parts.patch': 'result', + 'customXml.parts.remove': 'result', + 'fields.get': 'result', + 'fields.insert': 'result', + 'fields.list': 'result', + 'fields.rebuild': 'result', + 'fields.remove': 'result', + 'footnotes.configure': 'result', + 'footnotes.get': 'result', + 'footnotes.insert': 'result', + 'footnotes.list': 'result', + 'footnotes.remove': 'result', + 'footnotes.update': 'result', + 'format.paragraph.clearDirection': 'result', + 'format.paragraph.setDirection': 'result', + 'hyperlinks.get': 'result', + 'hyperlinks.insert': 'result', + 'hyperlinks.list': 'result', + 'hyperlinks.patch': 'result', + 'hyperlinks.remove': 'result', + 'hyperlinks.wrap': 'result', + 'images.crop': 'result', + 'images.flip': 'result', + 'images.insertCaption': 'result', + 'images.removeCaption': 'result', + 'images.replaceSource': 'result', + 'images.resetCrop': 'result', + 'images.rotate': 'result', + 'images.scale': 'result', + 'images.setAltText': 'result', + 'images.setDecorative': 'result', + 'images.setHyperlink': 'result', + 'images.setLockAspectRatio': 'result', + 'images.setName': 'result', + 'images.updateCaption': 'result', + 'index.configure': 'result', + 'index.entries.get': 'result', + 'index.entries.insert': 'result', + 'index.entries.list': 'result', + 'index.entries.remove': 'result', + 'index.entries.update': 'result', + 'index.get': 'result', + 'index.insert': 'result', + 'index.list': 'result', + 'index.rebuild': 'result', + 'index.remove': 'result', + 'lists.applyStyle': 'result', + 'lists.delete': 'result', + 'lists.getStyle': 'result', + 'lists.merge': 'result', + 'lists.restartAt': 'result', + 'lists.setLevelLayout': 'result', + 'lists.setLevelNumberStyle': 'result', + 'lists.setLevelStart': 'result', + 'lists.setLevelText': 'result', + 'lists.setType': 'result', + 'lists.split': 'result', + 'metadata.attach': 'result', + 'metadata.get': 'result', + 'metadata.list': 'result', + 'metadata.remove': 'result', + 'metadata.resolve': 'result', + 'metadata.update': 'result', + 'permissionRanges.create': 'result', + 'permissionRanges.get': 'result', + 'permissionRanges.list': 'result', + 'permissionRanges.remove': 'result', + 'permissionRanges.updatePrincipal': 'result', + 'protection.clearEditingRestriction': 'result', + 'protection.get': 'result', + 'protection.setEditingRestriction': 'result', + 'ranges.resolve': 'result', + 'sections.clearHeaderFooterRef': 'result', + 'sections.clearPageBorders': 'result', + 'sections.get': 'result', + 'sections.list': 'result', + 'sections.setBreakType': 'result', + 'sections.setColumns': 'result', + 'sections.setHeaderFooterMargins': 'result', + 'sections.setHeaderFooterRef': 'result', + 'sections.setLineNumbering': 'result', + 'sections.setLinkToPrevious': 'result', + 'sections.setOddEvenHeadersFooters': 'result', + 'sections.setPageBorders': 'result', + 'sections.setPageMargins': 'result', + 'sections.setPageNumbering': 'result', + 'sections.setPageSetup': 'result', + 'sections.setSectionDirection': 'result', + 'sections.setTitlePage': 'result', + 'sections.setVerticalAlign': 'result', + 'selection.current': 'result', + 'tables.applyPreset': 'result', + 'tables.applyStyle': 'result', + 'tables.setBorders': 'result', + 'tables.setCellText': 'result', + 'tables.setTableOptions': 'result', }; // --------------------------------------------------------------------------- @@ -745,4 +1364,213 @@ export const OPERATION_FAMILY: Record = 'diff.capture': 'diff', 'diff.compare': 'diff', 'diff.apply': 'diff', + + // Every doc-api operation must declare a family. + // 'general' mirrors the existing runtime fallback in error-mapping.ts. + 'authorities.configure': 'general', + 'authorities.entries.get': 'general', + 'authorities.entries.insert': 'general', + 'authorities.entries.list': 'general', + 'authorities.entries.remove': 'general', + 'authorities.entries.update': 'general', + 'authorities.get': 'general', + 'authorities.insert': 'general', + 'authorities.list': 'general', + 'authorities.rebuild': 'general', + 'authorities.remove': 'general', + 'bookmarks.get': 'general', + 'bookmarks.insert': 'general', + 'bookmarks.list': 'general', + 'bookmarks.remove': 'general', + 'bookmarks.rename': 'general', + 'captions.configure': 'general', + 'captions.get': 'general', + 'captions.insert': 'general', + 'captions.list': 'general', + 'captions.remove': 'general', + 'captions.update': 'general', + 'citations.bibliography.configure': 'general', + 'citations.bibliography.get': 'general', + 'citations.bibliography.insert': 'general', + 'citations.bibliography.rebuild': 'general', + 'citations.bibliography.remove': 'general', + 'citations.get': 'general', + 'citations.insert': 'general', + 'citations.list': 'general', + 'citations.remove': 'general', + 'citations.sources.get': 'general', + 'citations.sources.insert': 'general', + 'citations.sources.list': 'general', + 'citations.sources.remove': 'general', + 'citations.sources.update': 'general', + 'citations.update': 'general', + 'contentControls.appendContent': 'general', + 'contentControls.checkbox.getState': 'general', + 'contentControls.checkbox.setState': 'general', + 'contentControls.checkbox.setSymbolPair': 'general', + 'contentControls.checkbox.toggle': 'general', + 'contentControls.choiceList.getItems': 'general', + 'contentControls.choiceList.setItems': 'general', + 'contentControls.choiceList.setSelected': 'general', + 'contentControls.clearBinding': 'general', + 'contentControls.clearContent': 'general', + 'contentControls.copy': 'general', + 'contentControls.date.clearValue': 'general', + 'contentControls.date.setCalendar': 'general', + 'contentControls.date.setDisplayFormat': 'general', + 'contentControls.date.setDisplayLocale': 'general', + 'contentControls.date.setStorageFormat': 'general', + 'contentControls.date.setValue': 'general', + 'contentControls.delete': 'general', + 'contentControls.get': 'general', + 'contentControls.getBinding': 'general', + 'contentControls.getContent': 'general', + 'contentControls.getParent': 'general', + 'contentControls.getRawProperties': 'general', + 'contentControls.group.ungroup': 'general', + 'contentControls.group.wrap': 'general', + 'contentControls.insertAfter': 'general', + 'contentControls.insertBefore': 'general', + 'contentControls.list': 'general', + 'contentControls.listChildren': 'general', + 'contentControls.listInRange': 'general', + 'contentControls.move': 'general', + 'contentControls.normalizeTagPayload': 'general', + 'contentControls.normalizeWordCompatibility': 'general', + 'contentControls.patch': 'general', + 'contentControls.patchRawProperties': 'general', + 'contentControls.prependContent': 'general', + 'contentControls.repeatingSection.cloneItem': 'general', + 'contentControls.repeatingSection.deleteItem': 'general', + 'contentControls.repeatingSection.insertItemAfter': 'general', + 'contentControls.repeatingSection.insertItemBefore': 'general', + 'contentControls.repeatingSection.listItems': 'general', + 'contentControls.repeatingSection.setAllowInsertDelete': 'general', + 'contentControls.replaceContent': 'general', + 'contentControls.selectByTag': 'general', + 'contentControls.selectByTitle': 'general', + 'contentControls.setBinding': 'general', + 'contentControls.setLockMode': 'general', + 'contentControls.setType': 'general', + 'contentControls.text.clearValue': 'general', + 'contentControls.text.setMultiline': 'general', + 'contentControls.text.setValue': 'general', + 'contentControls.unwrap': 'general', + 'contentControls.validateWordCompatibility': 'general', + 'contentControls.wrap': 'general', + 'create.contentControl': 'general', + 'create.sectionBreak': 'general', + 'crossRefs.get': 'general', + 'crossRefs.insert': 'general', + 'crossRefs.list': 'general', + 'crossRefs.rebuild': 'general', + 'crossRefs.remove': 'general', + 'customXml.parts.create': 'general', + 'customXml.parts.get': 'general', + 'customXml.parts.list': 'general', + 'customXml.parts.patch': 'general', + 'customXml.parts.remove': 'general', + 'fields.get': 'general', + 'fields.insert': 'general', + 'fields.list': 'general', + 'fields.rebuild': 'general', + 'fields.remove': 'general', + 'footnotes.configure': 'general', + 'footnotes.get': 'general', + 'footnotes.insert': 'general', + 'footnotes.list': 'general', + 'footnotes.remove': 'general', + 'footnotes.update': 'general', + 'format.paragraph.clearDirection': 'general', + 'format.paragraph.setDirection': 'general', + 'headerFooters.get': 'general', + 'headerFooters.list': 'general', + 'headerFooters.parts.create': 'general', + 'headerFooters.parts.delete': 'general', + 'headerFooters.parts.list': 'general', + 'headerFooters.refs.clear': 'general', + 'headerFooters.refs.set': 'general', + 'headerFooters.refs.setLinkedToPrevious': 'general', + 'headerFooters.resolve': 'general', + 'hyperlinks.get': 'general', + 'hyperlinks.insert': 'general', + 'hyperlinks.list': 'general', + 'hyperlinks.patch': 'general', + 'hyperlinks.remove': 'general', + 'hyperlinks.wrap': 'general', + 'images.crop': 'general', + 'images.flip': 'general', + 'images.insertCaption': 'general', + 'images.removeCaption': 'general', + 'images.replaceSource': 'general', + 'images.resetCrop': 'general', + 'images.rotate': 'general', + 'images.scale': 'general', + 'images.setAltText': 'general', + 'images.setDecorative': 'general', + 'images.setHyperlink': 'general', + 'images.setLockAspectRatio': 'general', + 'images.setName': 'general', + 'images.updateCaption': 'general', + 'index.configure': 'general', + 'index.entries.get': 'general', + 'index.entries.insert': 'general', + 'index.entries.list': 'general', + 'index.entries.remove': 'general', + 'index.entries.update': 'general', + 'index.get': 'general', + 'index.insert': 'general', + 'index.list': 'general', + 'index.rebuild': 'general', + 'index.remove': 'general', + 'lists.applyStyle': 'general', + 'lists.delete': 'general', + 'lists.getStyle': 'general', + 'lists.merge': 'general', + 'lists.restartAt': 'general', + 'lists.setLevelLayout': 'general', + 'lists.setLevelNumberStyle': 'general', + 'lists.setLevelStart': 'general', + 'lists.setLevelText': 'general', + 'lists.setType': 'general', + 'lists.split': 'general', + 'metadata.attach': 'general', + 'metadata.get': 'general', + 'metadata.list': 'general', + 'metadata.remove': 'general', + 'metadata.resolve': 'general', + 'metadata.update': 'general', + 'permissionRanges.create': 'general', + 'permissionRanges.get': 'general', + 'permissionRanges.list': 'general', + 'permissionRanges.remove': 'general', + 'permissionRanges.updatePrincipal': 'general', + 'protection.clearEditingRestriction': 'general', + 'protection.get': 'general', + 'protection.setEditingRestriction': 'general', + 'ranges.resolve': 'general', + 'sections.clearHeaderFooterRef': 'general', + 'sections.clearPageBorders': 'general', + 'sections.get': 'general', + 'sections.list': 'general', + 'sections.setBreakType': 'general', + 'sections.setColumns': 'general', + 'sections.setHeaderFooterMargins': 'general', + 'sections.setHeaderFooterRef': 'general', + 'sections.setLineNumbering': 'general', + 'sections.setLinkToPrevious': 'general', + 'sections.setOddEvenHeadersFooters': 'general', + 'sections.setPageBorders': 'general', + 'sections.setPageMargins': 'general', + 'sections.setPageNumbering': 'general', + 'sections.setPageSetup': 'general', + 'sections.setSectionDirection': 'general', + 'sections.setTitlePage': 'general', + 'sections.setVerticalAlign': 'general', + 'selection.current': 'general', + 'tables.applyPreset': 'general', + 'tables.applyStyle': 'general', + 'tables.setBorders': 'general', + 'tables.setCellText': 'general', + 'tables.setTableOptions': 'general', }; diff --git a/apps/cli/src/host/server.test.ts b/apps/cli/src/host/server.test.ts new file mode 100644 index 0000000000..894d83db18 --- /dev/null +++ b/apps/cli/src/host/server.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { parseHostCommandTokens } from './server'; +import { CliError } from '../lib/errors'; + +describe('parseHostCommandTokens', () => { + test('parses --stdio', () => { + expect(parseHostCommandTokens(['--stdio'])).toEqual({ + stdio: true, + help: false, + requestTimeoutMs: undefined, + }); + }); + + test('parses --help and -h', () => { + expect(parseHostCommandTokens(['--help'])).toEqual({ + stdio: false, + help: true, + requestTimeoutMs: undefined, + }); + expect(parseHostCommandTokens(['-h'])).toEqual({ + stdio: false, + help: true, + requestTimeoutMs: undefined, + }); + }); + + test('rejects unknown options', () => { + expect(() => parseHostCommandTokens(['--bogus'])).toThrow(CliError); + }); + + describe('--request-timeout-ms', () => { + test('accepts space-separated value', () => { + expect(parseHostCommandTokens(['--stdio', '--request-timeout-ms', '120000'])).toEqual({ + stdio: true, + help: false, + requestTimeoutMs: 120000, + }); + }); + + test('accepts equals-separated value', () => { + expect(parseHostCommandTokens(['--stdio', '--request-timeout-ms=180000'])).toEqual({ + stdio: true, + help: false, + requestTimeoutMs: 180000, + }); + }); + + test('rejects missing value', () => { + expect(() => parseHostCommandTokens(['--request-timeout-ms'])).toThrow(/positive finite number/); + }); + + test('rejects empty value', () => { + expect(() => parseHostCommandTokens(['--request-timeout-ms='])).toThrow(/positive finite number/); + }); + + test('rejects non-numeric value', () => { + expect(() => parseHostCommandTokens(['--request-timeout-ms', 'soon'])).toThrow(/positive finite number/); + }); + + test('rejects zero and negatives', () => { + expect(() => parseHostCommandTokens(['--request-timeout-ms', '0'])).toThrow(/positive finite number/); + expect(() => parseHostCommandTokens(['--request-timeout-ms', '-1'])).toThrow(/positive finite number/); + }); + + test('accepts positive non-integer (float) values', () => { + expect(parseHostCommandTokens(['--request-timeout-ms', '1500.5'])).toEqual({ + stdio: false, + help: false, + requestTimeoutMs: 1500.5, + }); + }); + + test('rejects NaN and Infinity', () => { + expect(() => parseHostCommandTokens(['--request-timeout-ms', 'NaN'])).toThrow(/positive finite number/); + expect(() => parseHostCommandTokens(['--request-timeout-ms', 'Infinity'])).toThrow(/positive finite number/); + expect(() => parseHostCommandTokens(['--request-timeout-ms', '-Infinity'])).toThrow(/positive finite number/); + }); + }); +}); diff --git a/apps/cli/src/host/server.ts b/apps/cli/src/host/server.ts index 21066ee43c..28872ce9b4 100644 --- a/apps/cli/src/host/server.ts +++ b/apps/cli/src/host/server.ts @@ -20,8 +20,9 @@ import { type JsonRpcRequest, } from './protocol'; -const HOST_HELP = `Usage:\n superdoc host --stdio\n`; +const HOST_HELP = `Usage:\n superdoc host --stdio [--request-timeout-ms ]\n`; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const REQUEST_TIMEOUT_FLAG = '--request-timeout-ms'; type HostServerOptions = { io: Pick; @@ -30,11 +31,20 @@ type HostServerOptions = { sessionPool?: SessionPool; }; -function parseHostCommandTokens(tokens: string[]): { stdio: boolean; help: boolean } { +type ParsedHostCommand = { + stdio: boolean; + help: boolean; + requestTimeoutMs?: number; +}; + +export function parseHostCommandTokens(tokens: string[]): ParsedHostCommand { let stdio = false; let help = false; + let requestTimeoutMs: number | undefined; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; - for (const token of tokens) { if (token === '--stdio') { stdio = true; continue; @@ -45,10 +55,32 @@ function parseHostCommandTokens(tokens: string[]): { stdio: boolean; help: boole continue; } + if (token === REQUEST_TIMEOUT_FLAG || token.startsWith(`${REQUEST_TIMEOUT_FLAG}=`)) { + const value = token === REQUEST_TIMEOUT_FLAG ? tokens[++i] : token.slice(REQUEST_TIMEOUT_FLAG.length + 1); + if (value == null || value === '') { + throw new CliError( + 'INVALID_ARGUMENT', + `host: ${REQUEST_TIMEOUT_FLAG} requires a positive finite number of milliseconds.`, + ); + } + // Accept any positive finite number โ€” `setTimeout` happily takes floats, + // and the SDK's `requestTimeoutMs` option is typed `number` so any value + // that was valid as a JS timer pre-fix must remain valid here. + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new CliError( + 'INVALID_ARGUMENT', + `host: ${REQUEST_TIMEOUT_FLAG} requires a positive finite number of milliseconds (got ${JSON.stringify(value)}).`, + ); + } + requestTimeoutMs = parsed; + continue; + } + throw new CliError('INVALID_ARGUMENT', `host: unknown option ${token}`); } - return { stdio, help }; + return { stdio, help, requestTimeoutMs }; } type SettledOutcome = @@ -316,7 +348,7 @@ export async function runHostStdio(tokens: string[], io: CliIO): Promise throw new CliError('INVALID_ARGUMENT', 'host: only --stdio is supported in v1.'); } - const server = new HostServer({ io }); + const server = new HostServer({ io, requestTimeoutMs: parsed.requestTimeoutMs }); const rl = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY, diff --git a/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts b/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts new file mode 100644 index 0000000000..0df44fc460 --- /dev/null +++ b/apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts @@ -0,0 +1,569 @@ +/** + * SD-3214 regression: comment metadata authored on the browser side (Y.Array) + * must reach the headless SDK's CommentEntityStore so `doc.comments.list()` + * surfaces text / creatorName / creatorEmail / createdTime. + * + * Architectural gap (pre-fix): browser clients write to BOTH + * `ydoc.getArray('comments')` (metadata) and the PM XmlFragment (anchor marks). + * The headless CLI bridge only handled the WRITE direction. The fix wires + * `bridge.attachEditor(editor)` to observe Y.Array and feed entries through + * `syncCommentEntitiesFromCollaboration`. + * + * This test focuses on the ENTITY STORE flow: when a Y.Array entry exists + * pre-open OR arrives post-open, its metadata should be readable via + * `doc.comments.list()`. (PM anchor presence is orthogonal โ€” it supplies + * target/anchoredText/status when the comment is anchored to text.) + */ +import { describe, expect, it } from 'vitest'; +import { Doc as YDoc, Map as YMap } from 'yjs'; +import { openDocument } from '../document'; + +function createIo() { + return { + stdout() {}, + stderr() {}, + async readStdinBytes() { + return new Uint8Array(); + }, + now() { + return Date.now(); + }, + }; +} + +function createProviderStub() { + const noop = () => {}; + return { + synced: true, + awareness: { + on: noop, + off: noop, + getStates: () => new Map(), + setLocalState: noop, + setLocalStateField: noop, + }, + on: noop, + off: noop, + connect: noop, + disconnect: noop, + destroy: noop, + }; +} + +/** + * Mirror the shape `addYComment` in collaboration-comments.js produces when + * a browser user creates a comment via SuperDoc.vue's normal flow. + */ +function pushBrowserAuthoredComment(ydoc: YDoc, comment: Record): void { + const yArray = ydoc.getArray('comments'); + const yComment = new YMap(Object.entries(comment)); + ydoc.transact( + () => { + yArray.push([yComment]); + }, + { user: { name: comment.creatorName, email: comment.creatorEmail } }, + ); +} + +describe('SD-3214: headless SDK reads browser-authored comment metadata', () => { + it('exposes creatorName, createdTime, and text from Y.Array entries pre-existing at open', async () => { + const ydoc = new YDoc(); + + // Browser authored this comment BEFORE the headless client connected. + pushBrowserAuthoredComment(ydoc, { + commentId: 'c-browser-pre', + commentText: 'Please review this clause.', + creatorName: 'Browser User', + creatorEmail: 'browser@example.com', + createdTime: 1700000000000, + isInternal: false, + }); + + const opened = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-doc', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + const result = opened.editor.doc.comments.list(); + opened.dispose(); + + const item = result.items.find((c) => c.id === 'c-browser-pre'); + expect(item, 'browser-authored comment should be listed by headless SDK').toBeDefined(); + expect(item?.text, 'commentText from Y.Array should reach SDK').toBe('Please review this clause.'); + expect(item?.creatorName, 'creatorName from Y.Array should reach SDK').toBe('Browser User'); + expect(item?.creatorEmail, 'creatorEmail from Y.Array should reach SDK').toBe('browser@example.com'); + expect(item?.createdTime, 'createdTime from Y.Array should reach SDK').toBe(1700000000000); + }); + + it('exposes metadata for browser-authored comments that arrive AFTER open via Y.Array observe', async () => { + const ydoc = new YDoc(); + + const opened = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-doc-late', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + // Browser authors the comment AFTER the headless client connected. + pushBrowserAuthoredComment(ydoc, { + commentId: 'c-browser-late', + commentText: 'A late comment.', + creatorName: 'Late Browser User', + creatorEmail: 'late@example.com', + createdTime: 1700000001000, + isInternal: false, + }); + + const result = opened.editor.doc.comments.list(); + opened.dispose(); + + const late = result.items.find((c) => c.id === 'c-browser-late'); + expect(late, 'late browser-authored comment should be listed').toBeDefined(); + expect(late?.text).toBe('A late comment.'); + expect(late?.creatorName).toBe('Late Browser User'); + expect(late?.creatorEmail).toBe('late@example.com'); + expect(late?.createdTime).toBe(1700000001000); + }); + + it('a remote update to an existing browser comment surfaces the new fields', async () => { + const ydoc = new YDoc(); + + pushBrowserAuthoredComment(ydoc, { + commentId: 'c-update', + commentText: 'v1', + creatorName: 'Browser User', + creatorEmail: 'browser@example.com', + createdTime: 1700000002000, + }); + + const opened = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-doc-update', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + // Browser edits the same comment. + const yArray = ydoc.getArray>('comments'); + ydoc.transact( + () => { + yArray.delete(0, 1); + yArray.push([ + new YMap( + Object.entries({ + commentId: 'c-update', + commentText: 'v2', + creatorName: 'Browser User', + creatorEmail: 'browser@example.com', + createdTime: 1700000002000, + }), + ), + ]); + }, + { user: { name: 'Browser User', email: 'browser@example.com' } }, + ); + + const result = opened.editor.doc.comments.list(); + opened.dispose(); + + const item = result.items.find((c) => c.id === 'c-update'); + expect(item?.text).toBe('v2'); + }); +}); + +// --------------------------------------------------------------------------- +// Two-client validation (SD-3214 follow-up). +// Option A: two Editors on a single shared Y.Doc โ€” proves Yjs change broadcast +// reaches Session B's bridge through the real write path that a browser SuperDoc +// would use (editor.commands.addComment + bridge onCommentsUpdate โ†’ addYComment). +// Option B: two distinct Y.Docs synced by relaying updates โ€” closer to +// wire-protocol reality. Verifies origin filtering survives applyUpdate hops. +// --------------------------------------------------------------------------- + +import { applyUpdate as yApplyUpdate, encodeStateAsUpdate as yEncodeStateAsUpdate } from 'yjs'; + +describe('SD-3214: two-client end-to-end', () => { + it('Option A โ€” shared Y.Doc: Session B sees a comment authored by Session A', async () => { + const ydoc = new YDoc(); + + // Session A โ€” author (the "browser" side, run as a headless Editor here so + // the test stays in Node; the code path it exercises is identical to what + // SuperDoc.vue runs). + const sessionA = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-shared-doc', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + const insertA = sessionA.editor.doc.create.paragraph({ + at: { kind: 'documentEnd' }, + text: 'A clause about indemnification.', + }); + expect(insertA.success).toBe(true); + const matchA = sessionA.editor.doc.query.match({ + select: { type: 'text', pattern: 'indemnification' }, + require: 'first', + }); + const matchBlock = matchA.items[0].blocks[0]; + const create = sessionA.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock.blockId, range: matchBlock.range } as never, + text: 'Please review this clause.', + }); + expect(create.success).toBe(true); + + // Session B โ€” agent connects to the SAME ydoc. + const sessionB = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-shared-doc', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: false, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + const listB = sessionB.editor.doc.comments.list(); + sessionA.dispose(); + sessionB.dispose(); + + expect(listB.items.length).toBeGreaterThanOrEqual(1); + const item = listB.items[0]; + // Anchor-derived fields survive via PM/Yjs sync. + expect(item.target).toBeDefined(); + // Y.Array-derived metadata โ€” the field set the ticket flagged as empty. + expect(item.text).toBe('Please review this clause.'); + expect(item.creatorName).toBe('Browser User'); + expect(item.creatorEmail).toBe('browser@example.com'); + expect(item.createdTime).toBeTypeOf('number'); + }); + + it('Option A โ€” origin filter: Session A does not double-sync its own writes through observe', async () => { + // After A writes a comment, A's bridge observer fires for its own write. + // The origin filter must skip โ€” otherwise the entry could be processed + // twice (idempotent thanks to upsert, but wasted work and a hint of a + // broken filter that would matter under deletion). + const ydoc = new YDoc(); + const sessionA = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-self-echo', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Author', email: 'author@example.com' }, + }); + sessionA.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A short clause.' }); + const matchBlock = sessionA.editor.doc.query.match({ + select: { type: 'text', pattern: 'clause' }, + require: 'first', + }).items[0].blocks[0]; + sessionA.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock.blockId, range: matchBlock.range } as never, + text: 'mine', + }); + + // Trigger any pending observers by reading. + const list = sessionA.editor.doc.comments.list(); + sessionA.dispose(); + + expect(list.items).toHaveLength(1); + expect(list.items[0].text).toBe('mine'); + expect(list.items[0].creatorName).toBe('Author'); + }); + + it('Option B โ€” two Y.Docs synced via update relay: Session B sees the metadata', async () => { + const docA = new YDoc(); + const docB = new YDoc(); + + // Manual sync relay โ€” simulates a network bridge. Origin tags prevent + // infinite echo loops. + const relayAtoB = (update: Uint8Array, origin: unknown) => { + if (origin === 'from-B') return; + yApplyUpdate(docB, update, 'from-A'); + }; + const relayBtoA = (update: Uint8Array, origin: unknown) => { + if (origin === 'from-A') return; + yApplyUpdate(docA, update, 'from-B'); + }; + docA.on('update', relayAtoB); + docB.on('update', relayBtoA); + + const sessionA = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-two-docs', + ydoc: docA, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + sessionA.editor.doc.create.paragraph({ + at: { kind: 'documentEnd' }, + text: 'A clause on confidentiality.', + }); + const matchA = sessionA.editor.doc.query.match({ + select: { type: 'text', pattern: 'confidentiality' }, + require: 'first', + }); + const matchBlockA = matchA.items[0].blocks[0]; + sessionA.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlockA.blockId, range: matchBlockA.range } as never, + text: 'Confidentiality should cover IP.', + }); + + // Seed docB from docA before opening Session B โ€” mimics a fresh client + // joining the room after some history exists. + yApplyUpdate(docB, yEncodeStateAsUpdate(docA), 'from-A'); + + const sessionB = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-two-docs', + ydoc: docB, + collaborationProvider: createProviderStub(), + isNewFile: false, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + const listB = sessionB.editor.doc.comments.list(); + sessionA.dispose(); + sessionB.dispose(); + docA.off('update', relayAtoB); + docB.off('update', relayBtoA); + + expect(listB.items.length).toBeGreaterThanOrEqual(1); + const item = listB.items[0]; + expect(item.text).toBe('Confidentiality should cover IP.'); + expect(item.creatorName).toBe('Browser User'); + expect(item.creatorEmail).toBe('browser@example.com'); + }); + + it('Option A โ€” remote delete: when a browser removes a comment from Y.Array, Session B prunes the entry', async () => { + // Scope: this validates Session B's READ-SIDE prune behavior. The browser + // delete is simulated by removing the entry from ydoc.getArray('comments') + // directly โ€” exactly what packages/superdoc/.../collaboration-comments.js + // deleteYComment does. The CLI's own write-side delete bridging is tracked + // separately; SD-3214 covers metadata propagation from browser to agent. + const ydoc = new YDoc(); + + // Seed Y.Array with a browser-authored comment. + pushBrowserAuthoredComment(ydoc, { + commentId: 'c-to-delete', + commentText: 'Will be removed.', + creatorName: 'Browser User', + creatorEmail: 'browser@example.com', + createdTime: 1700000010000, + }); + + const opened = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-delete-readside', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + // Session sees the comment after attach. + const beforeList = opened.editor.doc.comments.list(); + expect(beforeList.items.find((c) => c.id === 'c-to-delete')).toBeDefined(); + + // Simulate browser deletion of the Y.Array entry. + const yArr = ydoc.getArray>('comments'); + const idx = (yArr.toJSON() as Array>).findIndex((c) => c.commentId === 'c-to-delete'); + ydoc.transact( + () => { + yArr.delete(idx, 1); + }, + { user: { name: 'Browser User', email: 'browser@example.com' } }, + ); + + const afterList = opened.editor.doc.comments.list(); + opened.dispose(); + + expect(afterList.items.find((c) => c.id === 'c-to-delete')).toBeUndefined(); + }); + + it('CLI write-side delete: agent.comments.delete propagates to Y.Array (customer "agent resolves comments" flow)', async () => { + // The customer's headless agent needs to mutate comments and have those + // mutations reach other browser collaborators. resolveComment / removeComment + // engine commands don't emit `commentsUpdate`, so SD-3214's bridge fix + // pairs with wrapper-level emits in `comments-wrappers.ts`. + const ydoc = new YDoc(); + const session = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-cli-delete', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + session.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to delete.' }); + const matchBlock = session.editor.doc.query.match({ + select: { type: 'text', pattern: 'delete' }, + require: 'first', + }).items[0].blocks[0]; + session.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock!.blockId, range: matchBlock!.range } as never, + text: 'agent-authored', + }); + const yArr = ydoc.getArray('comments').toJSON() as Array>; + expect(yArr.length).toBe(1); + const targetId = yArr[0].commentId as string; + + // Agent deletes โ€” this is the write-side that previously didn't propagate. + const del = session.editor.doc.comments.delete({ commentId: targetId }); + expect(del.success).toBe(true); + + // Y.Array now reflects the delete (other collaborators would observe this). + const afterYArr = ydoc.getArray('comments').toJSON() as Array>; + session.dispose(); + expect(afterYArr).toHaveLength(0); + }); + + it('CLI write-side resolve: agent.comments.patch({status:resolved}) propagates resolvedTime to Y.Array', async () => { + const ydoc = new YDoc(); + const session = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-cli-resolve', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + session.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to resolve.' }); + const matchBlock = session.editor.doc.query.match({ + select: { type: 'text', pattern: 'resolve' }, + require: 'first', + }).items[0].blocks[0]; + session.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock!.blockId, range: matchBlock!.range } as never, + text: 'pending review', + }); + const initial = ydoc.getArray('comments').toJSON() as Array>; + const targetId = initial[0].commentId as string; + expect(initial[0].resolvedTime).toBeFalsy(); + + // Agent resolves via the public patch surface. + const patch = session.editor.doc.comments.patch({ commentId: targetId, status: 'resolved' }); + expect(patch.success).toBe(true); + + // Y.Array reflects the resolution. + const after = ydoc.getArray('comments').toJSON() as Array>; + session.dispose(); + expect(after).toHaveLength(1); + expect(after[0].isDone).toBe(true); + expect(typeof after[0].resolvedTime).toBe('number'); + }); + + it('Option B โ€” post-open browser write: a comment authored AFTER the agent connects still propagates', async () => { + const docA = new YDoc(); + const docB = new YDoc(); + const relayAtoB = (update: Uint8Array, origin: unknown) => { + if (origin === 'from-B') return; + yApplyUpdate(docB, update, 'from-A'); + }; + const relayBtoA = (update: Uint8Array, origin: unknown) => { + if (origin === 'from-A') return; + yApplyUpdate(docA, update, 'from-B'); + }; + docA.on('update', relayAtoB); + docB.on('update', relayBtoA); + + // Agent connects FIRST. + const sessionB = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-late-author', + ydoc: docB, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + + // Browser connects and authors a comment. + const sessionA = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-late-author', + ydoc: docA, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Browser User', email: 'browser@example.com' }, + }); + sessionA.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A late clause.' }); + const matchBlock = sessionA.editor.doc.query.match({ + select: { type: 'text', pattern: 'late clause' }, + require: 'first', + }).items[0].blocks[0]; + sessionA.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock.blockId, range: matchBlock.range } as never, + text: 'Late comment.', + }); + + const listB = sessionB.editor.doc.comments.list(); + sessionA.dispose(); + sessionB.dispose(); + docA.off('update', relayAtoB); + docB.off('update', relayBtoA); + + const found = listB.items.find((c) => (c as { text?: string }).text === 'Late comment.'); + expect(found, 'browser-authored comment after agent connect should reach agent').toBeDefined(); + expect((found as { creatorName?: string }).creatorName).toBe('Browser User'); + }); + + // Codex P2 โ€” "Track own Y.Array writes before filtering": the bridge used + // to skip own-origin Y.Array events to avoid redundant work, but that also + // meant `previousSyncedIds` never learned about agent-authored comments. + // So a subsequent remote delete had no prior id to prune against, and the + // metadata stored at create time (text / creatorName / createdTime / โ€ฆ) + // would keep surfacing through doc.comments.list() even though the + // canonical Y.Array entry was gone. + // + // The fix updates `previousSyncedIds` on every observer fire, including + // own-origin ones. We assert the entity-store-resident metadata is pruned + // here; the PM anchor mark is local to this session and (correctly) + // outlives a Y.Array-only delete simulation, so we don't assert on + // list-membership โ€” only on the stale-metadata symptom Codex described. + it('agent-authored entity-store metadata is pruned when a remote client deletes it from Y.Array', async () => { + const ydoc = new YDoc(); + const session = await openDocument(undefined, createIo(), { + documentId: 'sd-3214-own-write-then-remote-delete', + ydoc, + collaborationProvider: createProviderStub(), + isNewFile: true, + user: { name: 'Headless Agent', email: 'agent@superdoc.dev' }, + }); + session.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'Agent-authored body.' }); + const matchBlock = session.editor.doc.query.match({ + select: { type: 'text', pattern: 'Agent-authored' }, + require: 'first', + }).items[0].blocks[0]; + session.editor.doc.comments.create({ + target: { kind: 'text', blockId: matchBlock!.blockId, range: matchBlock!.range } as never, + text: 'agent says review this', + }); + + const yArr = ydoc.getArray>('comments'); + const seeded = yArr.toJSON() as Array>; + expect(seeded).toHaveLength(1); + const targetId = seeded[0].commentId as string; + + const before = session.editor.doc.comments.list().items.find((c) => c.id === targetId); + expect(before, 'comment should be visible before remote delete').toBeDefined(); + expect(before?.text, 'agent-authored text should be present pre-delete').toBe('agent says review this'); + expect(before?.creatorName).toBe('Headless Agent'); + + // Remote client (different user origin) deletes the Y.Array entry. + ydoc.transact( + () => { + const idx = (yArr.toJSON() as Array>).findIndex((c) => c.commentId === targetId); + yArr.delete(idx, 1); + }, + { user: { name: 'Other Browser', email: 'other@example.com' } }, + ); + + const after = session.editor.doc.comments.list().items.find((c) => c.id === targetId); + session.dispose(); + + // The entity-store record carrying the rich metadata must be pruned. PM + // anchor presence is a separate concern; this test scopes to the + // metadata symptom Codex flagged ("stale local store entry"). + expect(after?.text, 'stale agent-authored text must be pruned after remote delete').toBeUndefined(); + expect(after?.creatorName, 'stale creatorName must be pruned after remote delete').toBeUndefined(); + expect(after?.creatorEmail, 'stale creatorEmail must be pruned after remote delete').toBeUndefined(); + expect(after?.createdTime, 'stale createdTime must be pruned after remote delete').toBeUndefined(); + }); +}); diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 5b3e5faf2c..bd01d7347d 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -222,6 +222,11 @@ export async function openDocument( // afterCommit hooks are always wired, including in headless CLI sessions. initPartsRuntime(editor as never); + // SD-3214: bridge observes ydoc.getArray('comments') and feeds remote + // (browser-authored) metadata into the editor's CommentEntityStore so the + // headless SDK can read text/creatorName/createdTime via doc.comments.list(). + commentBridge?.attachEditor(editor as never); + // Apply content override post-init. // - markdown: DOM-free AST pipeline // - plainText: builds PM paragraphs directly, preserving all whitespace diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index dc0b02957b..d4853703c8 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -30,6 +30,7 @@ export type CliErrorCode = | 'TRACK_CHANGE_COMMAND_UNAVAILABLE' | 'TRACK_CHANGE_CONFLICT' | 'COMMAND_FAILED' + | 'OPERATION_HINT_MISSING' | 'UNSUPPORTED_FORMAT' | 'TIMEOUT' // Plan-engine error codes โ€” passed through from document-api adapters diff --git a/apps/cli/src/lib/headless-comment-bridge.ts b/apps/cli/src/lib/headless-comment-bridge.ts index adab125c2d..247870e647 100644 --- a/apps/cli/src/lib/headless-comment-bridge.ts +++ b/apps/cli/src/lib/headless-comment-bridge.ts @@ -11,9 +11,16 @@ */ import { Map as YMap } from 'yjs'; -import type { Doc as YDoc, Array as YArray } from 'yjs'; +import type { Doc as YDoc, Array as YArray, YArrayEvent } from 'yjs'; +import { syncCommentEntitiesFromCollaboration } from 'superdoc/super-editor'; import type { UserIdentity } from './types'; +// Editor handle is intentionally typed as `unknown` here โ€” the bridge only +// forwards it to `syncCommentEntitiesFromCollaboration`, which owns the +// engine-specific knowledge. Keeping the type opaque preserves the CLI's +// engine-agnostic boundary. +type EditorHandle = Parameters[0]; + // --------------------------------------------------------------------------- // Yjs write helpers (mirrors collaboration-comments.js) // --------------------------------------------------------------------------- @@ -144,7 +151,23 @@ export interface HeadlessCommentBridgeResult { onCommentsUpdate: (params: Record) => void; onCommentsLoaded: (params: { editor: unknown; comments: unknown[] }) => void; }; - /** Cleanup โ€” clears internal registry */ + /** + * Wire the bridge to an Editor instance once `Editor.open()` resolves. + * + * Seeds the editor's CommentEntityStore from the current Y.Array contents, + * then installs a Y.Array observer that mirrors remote (other-client) + * comment additions, updates, and removals into the store. Without this, + * `editor.doc.comments.list()` only sees PM-anchor data for browser- + * authored comments and the text/creatorName/createdTime fields are empty. + * + * Origin filter: events whose `transaction.origin.user` matches the bridge + * user are skipped โ€” those came from this client's own writes and are + * already in the store via the normal `onCommentsUpdate` path. + * + * Safe to call multiple times; only the most recent editor is observed. + */ + attachEditor(editor: EditorHandle): void; + /** Cleanup โ€” clears internal registry and detaches Y.Array observer */ dispose(): void; } @@ -274,6 +297,54 @@ export function buildHeadlessCommentBridge(ydoc: unknown, user?: UserIdentity): ); } + // ---- Y.Array โ†’ CommentEntityStore observer (SD-3214) ---- + + let attachedEditor: EditorHandle | null = null; + let yArrayObserver: ((event: YArrayEvent>) => void) | null = null; + // Set of commentIds previously synced from Y.Array. The helper uses this + // to detect remote deletions and prune them from the store. + let previousSyncedIds: ReadonlySet = new Set(); + + function syncYArrayToStore(): void { + if (!attachedEditor) return; + const entries = yArray.toJSON() as Array>; + previousSyncedIds = syncCommentEntitiesFromCollaboration(attachedEditor, entries, { + previouslySynced: previousSyncedIds, + }); + } + + function detachYArrayObserver(): void { + if (yArrayObserver) { + yArray.unobserve(yArrayObserver); + yArrayObserver = null; + } + } + + function attachEditor(editor: EditorHandle): void { + detachYArrayObserver(); + attachedEditor = editor; + // Reset the prior-sync set so a re-attach (e.g. document re-open) doesn't + // prune entries that are genuinely fresh from this editor's perspective. + previousSyncedIds = new Set(); + + // Initial seed: pull whatever is already in the room. + syncYArrayToStore(); + + yArrayObserver = () => { + // Re-sync on every Y.Array event, including own-origin writes. For own + // writes the store is already coherent (the wrapper's `commentsUpdate` + // emit pre-populates it before this observer fires), but the prune + // step relies on `previousSyncedIds` knowing every collab-synced id โ€” + // including ids we authored ourselves โ€” so a later remote delete of + // an agent-authored comment can be detected and cascaded. The sync + // is idempotent for entries already present, so iterating over our + // own writes is a no-op on store contents and only refreshes the + // synced-id bookkeeping. + syncYArrayToStore(); + }; + yArray.observe(yArrayObserver); + } + return { editorOptions: { isCommentsEnabled: true, @@ -281,7 +352,11 @@ export function buildHeadlessCommentBridge(ydoc: unknown, user?: UserIdentity): onCommentsUpdate: handleCommentsUpdate, onCommentsLoaded: handleCommentsLoaded, }, + attachEditor, dispose() { + detachYArrayObserver(); + attachedEditor = null; + previousSyncedIds = new Set(); registry.clear(); }, }; diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index fea04e9f0b..c140fa1af9 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -10,7 +10,7 @@ */ import { COMMAND_CATALOG } from '@superdoc/document-api'; -import { RESPONSE_ENVELOPE_KEY, SUCCESS_VERB } from '../cli/operation-hints.js'; +import { SUCCESS_VERB } from '../cli/operation-hints.js'; import type { CliExposedOperationId } from '../cli/operation-set.js'; import { cliCommandTokens } from '../cli/operation-set.js'; import { assertExpectedRevision, markContextUpdated, withActiveContext, writeContextMetadata } from './context.js'; @@ -24,6 +24,7 @@ import { import { mapInvokeError, mapFailedReceipt } from './error-mapping.js'; import { CliError } from './errors.js'; import { formatOutput } from './output-formatters.js'; +import { resolveResponseEnvelopeKey } from './response-envelope.js'; import { syncCollaborativeSessionSnapshot } from './session-collab.js'; import { PRE_INVOKE_HOOKS, POST_INVOKE_HOOKS } from './special-handlers.js'; import type { CommandExecution } from './types.js'; @@ -78,13 +79,11 @@ function invokeOperation( } function buildEnvelopeData( - operationId: CliExposedOperationId, + envelopeKey: string | null, document: DocumentPayload, result: unknown, extras: Record, ): Record { - const envelopeKey = RESPONSE_ENVELOPE_KEY[operationId]; - if (envelopeKey === null) { const resultObj = typeof result === 'object' && result != null ? result : {}; return { document, ...(resultObj as Record), ...extras }; @@ -112,6 +111,9 @@ function buildPrettyOutput( export async function executeMutationOperation(request: DocOperationRequest): Promise { const { operationId, input, context } = request; + // Resolve the response envelope key up front so a hint-table drift fails + // before we open the document, run the mutation, or persist any state. + const envelopeKey = resolveResponseEnvelopeKey(operationId); const doc = readOptionalString(input, 'doc'); const outPath = readOptionalString(input, 'out'); const dryRun = readBoolean(input, 'dryRun'); @@ -162,7 +164,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr return { command: commandName, data: { - ...buildEnvelopeData(operationId, document, result, { changeMode, dryRun: true }), + ...buildEnvelopeData(envelopeKey, document, result, { changeMode, dryRun: true }), output: outPath ? { path: outPath, skippedWrite: true } : undefined, }, pretty: `Revision 0: dry run`, @@ -172,7 +174,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr const output = outPath ? await exportToPath(opened.editor, outPath, force) : undefined; return { command: commandName, - data: buildEnvelopeData(operationId, document, result, { + data: buildEnvelopeData(envelopeKey, document, result, { changeMode, dryRun: false, output, @@ -214,7 +216,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr return { command: commandName, data: { - ...buildEnvelopeData(operationId, document, result, { changeMode, dryRun: true }), + ...buildEnvelopeData(envelopeKey, document, result, { changeMode, dryRun: true }), context: { dirty: metadata.dirty, revision: metadata.revision }, output: outPath ? { path: outPath, skippedWrite: true } : undefined, }, @@ -262,7 +264,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr return { command: commandName, - data: buildEnvelopeData(operationId, document, result, { + data: buildEnvelopeData(envelopeKey, document, result, { changeMode, dryRun: false, context: { dirty: updatedMetadata.dirty, revision: updatedMetadata.revision }, diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index 46f288bb87..cdc3efc429 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -5,12 +5,13 @@ * operation-extra-invokers.ts with a single generic path. */ -import { RESPONSE_ENVELOPE_KEY, SUCCESS_VERB } from '../cli/operation-hints.js'; +import { SUCCESS_VERB } from '../cli/operation-hints.js'; import type { CliExposedOperationId } from '../cli/operation-set.js'; import { cliCommandTokens } from '../cli/operation-set.js'; import { withActiveContext } from './context.js'; import { openDocument, openSessionDocument, type EditorWithDoc } from './document.js'; import { mapInvokeError } from './error-mapping.js'; +import { resolveResponseEnvelopeKey } from './response-envelope.js'; import { formatOutput } from './output-formatters.js'; import { syncCollaborativeSessionSnapshot } from './session-collab.js'; import { PRE_INVOKE_HOOKS, POST_INVOKE_HOOKS } from './special-handlers.js'; @@ -63,12 +64,11 @@ const ECHO_INPUT_FIELDS: Partial> = { function buildEnvelopeData( operationId: CliExposedOperationId, + envelopeKey: string | null, document: DocumentPayload, result: unknown, input: Record, ): Record { - const envelopeKey = RESPONSE_ENVELOPE_KEY[operationId]; - const echoFields = ECHO_INPUT_FIELDS[operationId]; const extras: Record = {}; if (echoFields) { @@ -95,6 +95,12 @@ function buildPrettyOutput(operationId: CliExposedOperationId, document: Documen export async function executeReadOperation(request: DocOperationRequest): Promise { const { operationId, input, context } = request; + // Resolve the response envelope key up front so a hint-table drift fails + // before we open the document or run the operation. Reads have no on-disk + // side effects today, but doing this here keeps the guard symmetric with + // the mutation path and protects future read-time effects (e.g. collab + // snapshot sync) from running past a drift failure. + const envelopeKey = resolveResponseEnvelopeKey(operationId); const doc = readOptionalString(input, 'doc'); const commandName = deriveCommandName(operationId); @@ -112,7 +118,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis return { command: commandName, - data: buildEnvelopeData(operationId, document, result, input), + data: buildEnvelopeData(operationId, envelopeKey, document, result, input), pretty: buildPrettyOutput(operationId, document, result), }; } finally { @@ -148,7 +154,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis }; return { command: commandName, - data: buildEnvelopeData(operationId, document, result, input), + data: buildEnvelopeData(operationId, envelopeKey, document, result, input), pretty: buildPrettyOutput(operationId, document, result), }; } @@ -161,7 +167,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis }; return { command: commandName, - data: buildEnvelopeData(operationId, document, result, input), + data: buildEnvelopeData(operationId, envelopeKey, document, result, input), pretty: buildPrettyOutput(operationId, document, result), }; } finally { diff --git a/apps/cli/src/lib/response-envelope.ts b/apps/cli/src/lib/response-envelope.ts new file mode 100644 index 0000000000..81c4438ca7 --- /dev/null +++ b/apps/cli/src/lib/response-envelope.ts @@ -0,0 +1,23 @@ +import { RESPONSE_ENVELOPE_KEY } from '../cli/operation-hints.js'; +import type { CliExposedOperationId } from '../cli/operation-set.js'; +import { CliError } from './errors.js'; + +/** + * Resolves the response envelope key for a doc-backed CLI operation, failing + * closed if the hint table drifted from the operation set. The type system + * requires RESPONSE_ENVELOPE_KEY to cover every CliExposedOperationId, but + * apps/cli does not run `tsc --noEmit` in CI, so this is the runtime backstop. + * + * Callers MUST invoke this before any mutating step (opening the document, + * running the operation, persisting state). Resolving late leaves on-disk + * state advanced past an internal-error response. + */ +export function resolveResponseEnvelopeKey(operationId: CliExposedOperationId): string | null { + if (!Object.prototype.hasOwnProperty.call(RESPONSE_ENVELOPE_KEY, operationId)) { + throw new CliError( + 'OPERATION_HINT_MISSING', + `Internal error: operation '${operationId}' has no RESPONSE_ENVELOPE_KEY entry. Add one in apps/cli/src/cli/operation-hints.ts.`, + ); + } + return RESPONSE_ENVELOPE_KEY[operationId]; +} diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 1ecfc2ecda..d67934546d 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -60,6 +60,7 @@ "getting-started/frameworks/nuxt", "getting-started/frameworks/angular", "getting-started/frameworks/laravel", + "getting-started/frameworks/solid", "getting-started/frameworks/vanilla-js" ] }, diff --git a/apps/docs/document-api/migration.mdx b/apps/docs/document-api/migration.mdx index 7c5be39486..ee609c342f 100644 --- a/apps/docs/document-api/migration.mdx +++ b/apps/docs/document-api/migration.mdx @@ -175,7 +175,7 @@ superdoc.on('editorCreate', ({ editor }) => { ## Driving custom React UI -If your migration is also moving the UI side off legacy chains, the destination is `superdoc/ui/react`. Replace `superdoc.on('editor-update', ...)` loops and `useState(superdoc.activeEditor.commands.X)` patterns with the typed hooks: `useSuperDocCommand`, `useSuperDocSelection`, `useSuperDocComments`, `useSuperDocTrackChanges`, `useSuperDocDocument`. See [Custom UI](/editor/custom-ui/overview) for the full surface and the [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui). +If your migration is also moving the UI side off legacy chains, the destination is `superdoc/ui/react`. Replace `superdoc.on('editor-update', ...)` loops and `useState(superdoc.activeEditor.commands.X)` patterns with the typed hooks: `useSuperDocCommand`, `useSuperDocSelection`, `useSuperDocComments`, `useSuperDocTrackChanges`, `useSuperDocDocument`. See [Custom UI](/editor/custom-ui/overview) for the full surface and the [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/editor/custom-ui). ## Full reference diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 65fb4ab087..4efc168b52 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "30a98282620ba67a1197b0a2e228c6dd7203c4692c0ca15e56528855f02f38d1" + "sourceHash": "266b6bb8fa042ebd94c3da6e11652471f49c40a061960f3df9055cf64c4bcbbe" } diff --git a/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx b/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx index 842bc3cbad..7730402c6b 100644 --- a/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx +++ b/apps/docs/document-api/reference/format/paragraph/set-alignment.mdx @@ -1,14 +1,14 @@ --- title: format.paragraph.setAlignment sidebarTitle: format.paragraph.setAlignment -description: Set paragraph alignment (justification) on a paragraph-like block. +description: Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Set paragraph alignment (justification) on a paragraph-like block. +Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values. - Operation ID: `format.paragraph.setAlignment` - API member path: `editor.doc.format.paragraph.setAlignment(...)` @@ -103,6 +103,7 @@ Returns a ParagraphMutationResult; reports NO_OP if the alignment already matche "additionalProperties": false, "properties": { "alignment": { + "description": "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side.", "enum": [ "left", "center", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index f66c3713ee..49a58b3833 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -263,7 +263,7 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | | format.paragraph.resetDirectFormatting | editor.doc.format.paragraph.resetDirectFormatting(...) | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| format.paragraph.setAlignment | editor.doc.format.paragraph.setAlignment(...) | Set paragraph alignment (justification) on a paragraph-like block. | +| format.paragraph.setAlignment | editor.doc.format.paragraph.setAlignment(...) | Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values. | | format.paragraph.clearAlignment | editor.doc.format.paragraph.clearAlignment(...) | Remove direct paragraph alignment, reverting to style-defined or default alignment. | | format.paragraph.setIndentation | editor.doc.format.paragraph.setIndentation(...) | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | | format.paragraph.clearIndentation | editor.doc.format.paragraph.clearIndentation(...) | Remove all direct paragraph indentation. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 8a98f21be7..c886ed7c82 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -794,7 +794,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. | | `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values. | | `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | | `doc.format.paragraph.setIndentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | | `doc.format.paragraph.clearIndentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | @@ -1272,7 +1272,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. | | `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values. | | `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | | `doc.format.paragraph.set_indentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | | `doc.format.paragraph.clear_indentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | diff --git a/apps/docs/editor/custom-ui/context-menu.mdx b/apps/docs/editor/custom-ui/context-menu.mdx index 3d609f4c1d..9cd2b95a88 100644 --- a/apps/docs/editor/custom-ui/context-menu.mdx +++ b/apps/docs/editor/custom-ui/context-menu.mdx @@ -151,7 +151,7 @@ If you'd rather suppress the native menu in the empty case too, call `event.prev ## Worked example -The reference workspace at [`demos/custom-ui`](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui) wires the full pattern end-to-end. The four registrations below mirror the demo's `ContextMenuRegistrations.tsx`. They cover the three subjects the menu can act on: an entity, the selection, or the click point. +The reference workspace at [`demos/editor/custom-ui`](https://github.com/superdoc-dev/superdoc/tree/main/demos/editor/custom-ui) wires the full pattern end-to-end. The four registrations below mirror the demo's `ContextMenuRegistrations.tsx`. They cover the three subjects the menu can act on: an entity, the selection, or the click point. diff --git a/apps/docs/editor/custom-ui/overview.mdx b/apps/docs/editor/custom-ui/overview.mdx index a67478ec74..a6ac6eb02d 100644 --- a/apps/docs/editor/custom-ui/overview.mdx +++ b/apps/docs/editor/custom-ui/overview.mdx @@ -86,4 +86,4 @@ The controller surfaces this split directly. The toolbar reads `state.selection` ## Worked example -The [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/custom-ui) ships a full app on these surfaces: toolbar, custom command with keyboard shortcut, floating bubble menu, right-click context menu, comments sidebar with reply threads, tracked-change review, selection capture / restore, DOCX export and reimport. +The [reference workspace on GitHub](https://github.com/superdoc-dev/superdoc/tree/main/demos/editor/custom-ui) ships a full app on these surfaces: toolbar, custom command with keyboard shortcut, floating bubble menu, right-click context menu, comments sidebar with reply threads, tracked-change review, selection capture / restore, DOCX export and reimport. diff --git a/apps/docs/extensions/comments.mdx b/apps/docs/extensions/comments.mdx index 39c6d1c0cb..a4248078c1 100644 --- a/apps/docs/extensions/comments.mdx +++ b/apps/docs/extensions/comments.mdx @@ -246,8 +246,8 @@ const superdoc = new SuperDoc({ ## Events ```javascript -superdoc.on('commentsUpdate', ({ type, comment }) => { - // type: 'ADD' | 'deleted' | 'SELECTED' +superdoc.on('comments-update', ({ type, comment }) => { + // type: 'pending' | 'add' | 'update' | 'deleted' | 'new' | 'resolved' | 'selected' | ... }); ``` diff --git a/apps/docs/getting-started/frameworks/solid.mdx b/apps/docs/getting-started/frameworks/solid.mdx new file mode 100644 index 0000000000..415f59023a --- /dev/null +++ b/apps/docs/getting-started/frameworks/solid.mdx @@ -0,0 +1,127 @@ +--- +title: Solid Integration +sidebarTitle: Solid +keywords: 'solid docx editor, solidjs docx editor, solid word component, superdoc solid, microsoft word solid, solidjs document editor' +--- + +SuperDoc works in Solid through the core `superdoc` package. Mount it into a DOM element, clean it up on unmount, and drive it with Solid's fine-grained reactivity. + + +SuperDoc does not ship a first-party Solid wrapper. Use the core `superdoc` package directly, or build a community wrapper on top of it. + + +## Install + +```bash +npm install superdoc +``` + +## Quick start + +```tsx +import { onCleanup, onMount } from 'solid-js'; +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +export default function App() { + let superdoc: SuperDoc | undefined; + const editorId = 'superdoc-editor'; + + onMount(() => { + superdoc = new SuperDoc({ + selector: `#${editorId}`, + }); + }); + + onCleanup(() => { + superdoc?.destroy(); + }); + + return
; +} +``` + +## Core concepts + +### Document modes + +| Mode | Description | +| ------------ | ------------------------- | +| `editing` | Full editing capabilities | +| `viewing` | Read-only presentation | +| `suggesting` | Track changes mode | + +```tsx +new SuperDoc({ + selector: `#${editorId}`, + document: file(), + documentMode: 'editing', +}); +``` + +### User roles + +| Role | Can Edit | Can Suggest | Can View | +| ----------- | -------- | ----------- | -------- | +| `editor` | Yes | Yes | Yes | +| `suggester` | No | Yes | Yes | +| `viewer` | No | No | Yes | + +```tsx +new SuperDoc({ + selector: `#${editorId}`, + document: file(), + role: 'editor', +}); +``` + +## Handle file uploads + +```tsx +import { createEffect, createSignal, onCleanup, Show } from 'solid-js'; +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +function FileEditor() { + const [file, setFile] = createSignal(null); + const editorId = 'superdoc-editor'; + let superdoc: SuperDoc | undefined; + + createEffect(() => { + const selected = file(); + if (!selected) return; + + superdoc = new SuperDoc({ + selector: `#${editorId}`, + document: selected, + user: { name: 'User', email: 'user@company.com' }, + }); + + onCleanup(() => { + superdoc?.destroy(); + }); + }); + + return ( +
+ { + const selected = event.currentTarget.files?.[0]; + if (selected) setFile(selected); + }} + /> + +
+ +
+ ); +} +``` + +## Next steps + +- [React Integration](/getting-started/frameworks/react) - React setup +- [API Reference](/editor/superdoc/configuration) - Configuration options +- [Examples](https://github.com/superdoc-dev/superdoc/tree/main/examples/getting-started/solid) - Working examples diff --git a/apps/docs/scripts/validate-code-imports.ts b/apps/docs/scripts/validate-code-imports.ts index 8b8fedf71f..f5d0a9c93b 100644 --- a/apps/docs/scripts/validate-code-imports.ts +++ b/apps/docs/scripts/validate-code-imports.ts @@ -42,6 +42,7 @@ const EXACT_EXTERNAL_IMPORTS = new Set([ 'react', 'react-dom', 'react-dom/client', + 'solid-js', 'vue', 'pdfjs-dist', 'pdfjs-dist/build/pdf.mjs', diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 89fe71dd7a..4cd0a69ba6 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1506,7 +1506,8 @@ export const MCP_TOOL_CATALOG = { }, alignment: { enum: ['left', 'center', 'right', 'justify'], - description: "Required for action 'set_alignment'.", + description: + "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'.", }, left: { type: 'integer', @@ -2285,7 +2286,7 @@ export const MCP_TOOL_CATALOG = { { toolName: 'superdoc_comment', description: - 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target: {kind:"text", blockId:"", range:{start:, end:}} using the blockId and highlightRange from the search result. For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', + 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', inputSchema: { type: 'object', properties: { diff --git a/apps/mcp/src/generated/mcp-prompt.ts b/apps/mcp/src/generated/mcp-prompt.ts index 25cb3b0fdf..46435c5288 100644 --- a/apps/mcp/src/generated/mcp-prompt.ts +++ b/apps/mcp/src/generated/mcp-prompt.ts @@ -416,7 +416,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) \`\`\` diff --git a/demos/__tests__/playwright.config.ts b/demos/__tests__/playwright.config.ts index d3624f4110..66f3757703 100644 --- a/demos/__tests__/playwright.config.ts +++ b/demos/__tests__/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -11,8 +11,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // when running this suite locally without an explicit DEMO override. const demo = process.env.DEMO || 'custom-ui'; -// Demos are flat: demos// -const demoPath = `../${demo}`; +// Resolve the demo's working directory via the manifest. Old paths under +// demos// may now be shim READMEs; manifest sourcePath is the source +// of truth post-SD-3217. +const manifestPath = resolve(__dirname, '../manifest.json'); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Array<{ + id: string; + sourcePath?: string | null; + sourceRepo?: string; +}>; +const entry = manifest.find((e) => e.id === demo); +const sourcePath = entry?.sourceRepo === 'superdoc-dev/superdoc' ? entry?.sourcePath : null; +if (!sourcePath) { + throw new Error(`DEMO="${demo}" not found in demos/manifest.json or is not a local demo`); +} +const repoRoot = resolve(__dirname, '../..'); +const demoPath = relative(__dirname, resolve(repoRoot, sourcePath)); // Port mapping for non-Vite demos (these use their framework's default port) const portMap: Record = { diff --git a/demos/chrome-extension/README.md b/demos/chrome-extension/README.md new file mode 100644 index 0000000000..0d8e366c7a --- /dev/null +++ b/demos/chrome-extension/README.md @@ -0,0 +1,5 @@ +# Moved to demos/editor/integrations/chrome-extension + +The Chrome extension demo moved to [`demos/editor/integrations/chrome-extension`](../editor/integrations/chrome-extension). + +It now sits under `demos/editor/integrations/` to reflect that it integrates SuperDoc into a host browser surface, rather than being its own product workflow. diff --git a/demos/custom-ui/README.md b/demos/custom-ui/README.md index 48088cf15d..4b694d3079 100644 --- a/demos/custom-ui/README.md +++ b/demos/custom-ui/README.md @@ -1,87 +1,5 @@ -# SuperDoc Custom UI demo +# Moved to demos/editor/custom-ui -A reference workspace built on the `superdoc/ui/react` surface. Toolbar, comment threads, tracked-change review, custom commands, DOCX round-trip, in one app. +The custom-UI reference workspace moved to [`demos/editor/custom-ui`](../editor/custom-ui). -See the [Custom UI docs](https://docs.superdoc.dev/editor/custom-ui/overview) for the conceptual guide. - -This demo shows how the pieces compose in a real product, not a single-concept recipe. Read it alongside the docs above when you're wiring your own toolbar or panel. - -## Run - -Prerequisites: Node 20+, pnpm 9+, run from inside the SuperDoc monorepo. - -```bash -pnpm install -pnpm --filter superdoc run build -pnpm --filter @superdoc-dev/react run build -pnpm --filter custom-ui run dev -``` - -Open http://localhost:5189. - -## What you can do here - -- Click toolbar buttons (bold, italic, lists, undo, redo) wired through `useSuperDocCommand`. -- Insert a custom clause registered with `ui.commands.register`. The button works, and so does its keyboard shortcut `Mod-Shift-C`, declared on the registration rather than wired in a separate keydown listener. -- Switch between Edit and Suggest. In Suggest, every edit lands as a tracked change. -- Select text and watch the floating bubble menu appear next to the selection (anchored via `ui.selection.getAnchorRect()`, not `window.getSelection()`). -- Right-click on a tracked change, comment, inside a selection, or on plain text. The menu adapts to the click target: Accept / Reject / Resolve on entities, Copy / Comment on a selection, Insert clause here on plain caret-only text. -- Add a comment. The composer captures the selection on open, posts on submit, and restores the visible range on close so the user keeps their place. -- Accept or reject tracked changes. Decided ones move to a Resolved section. -- Export the doc, edit it in Word, click Import, watch the activity feed update. - -## Architecture - -``` -SuperDocUIProvider one controller per app -โ””โ”€โ”€ EditorMount + onReady + disableContextMenu - โ”œโ”€โ”€ Toolbar ui.commands + setDocumentMode - โ”œโ”€โ”€ SelectionPopover ui.selection.getAnchorRect, bubble menu over the selection - โ”œโ”€โ”€ ContextMenu ui.viewport.contextAt + ui.commands.getContextMenuItems(context) + item.invoke() - โ”œโ”€โ”€ ContextMenuRegistrations ui.commands.register({ contextMenu: { when } }) - โ””โ”€โ”€ ActivitySidebar ui.comments + ui.trackChanges + ui.selection - โ””โ”€โ”€ CommentComposer ui.selection.capture / restore + ui.comments.createFromCapture -``` - -Components consume the controller via `useSuperDocUI()`. They never reach into `editor.state` or `editor.view`. - -## Three surfaces, three subjects - -The demo keeps a strict separation between the three editor UI surfaces. Each one answers a different "what's the subject of this action?" question: - -| Surface | Subject | Items in the demo | -| --- | --- | --- | -| **Toolbar** | The **document** | Bold, Italic, Lists, Undo, Redo, Mode toggle, Insert clause, Export, Import. | -| **Floating bubble menu** | The **selection** | Bold, Italic, Comment on selection. | -| **Right-click context menu** | The **clicked target** | Accept / Reject on tracked change, Resolve on comment, Copy / Comment on selection (when the click is inside the selection rect), Insert clause here (when the click lands on plain caret-only text). | - -`ui.viewport.contextAt({ x, y })` returns one bundle with the click point, the entities under it, the resolved caret position, the live selection, and `insideSelection` (whether the click landed in the painted selection rects). Each predicate filters on the same shape its handler receives, so "Copy" / "Comment on selection" gate themselves on `insideSelection === true` and "Insert clause here" gates on `position !== null && entities.length === 0 && insideSelection !== true`. A stale selection elsewhere on the page can't leak into a right-click somewhere else. - -The `Insert clause here` handler reads `context.position.target` (a collapsed `SelectionTarget` at the click point) and passes it straight to `editor.doc.insert`. The same predicate the menu was filtered with becomes the target the action acts on. Without the bundle, the registration would have to insert against the user's prior selection somewhere else in the doc, making the label a lie. - -Right-click on plain text where no item matches falls through to the browser's native menu. The handler deliberately doesn't `preventDefault` when `getContextMenuItems(context)` returns nothing, so the user gets Copy / Paste / Inspect from the browser instead of a dead right-click. - -## The four custom-UI patterns - -1. **Floating selection toolbar.** `ui.selection.getAnchorRect({ placement: 'start' })` returns viewport-relative coords for the painted selection. Re-position on `useSuperDocSelection()` change plus `scroll`/`resize`. Don't reach for `window.getSelection()`; SuperDoc's painted DOM is separate from the offscreen ProseMirror DOM and the browser API returns the wrong rect. See `SelectionPopover.tsx`. - -2. **Right-click context menu.** Set `disableContextMenu` on `` to suppress the built-in. On `contextmenu`, call `ui.viewport.contextAt({ x, y })` to get the bundle, then `ui.commands.getContextMenuItems(context)` to get items contributed via `register({ contextMenu })`. Each item carries `invoke()`, which fires the registered `execute({ context })` with the bundle bound, so handlers act on the click target without the menu component threading payloads. Scope the listener with `ui.viewport.getHost()` instead of a CSS class. See `ContextMenu.tsx` and `ContextMenuRegistrations.tsx`. - -3. **Custom command + keyboard shortcut.** Declare `shortcut: 'Mod-Shift-C'` on the registration. The controller installs a single bubble-phase keydown listener scoped to the painted host; matched shortcuts dispatch through the same path the toolbar button uses. No per-command keymap wiring. See `InsertClauseButton.tsx`. - -4. **Composer capture + restore.** `ui.selection.capture()` on open holds the selection across focus moves. `ui.comments.createFromCapture(captured, { text })` posts the comment using the frozen target. `ui.selection.restore(captured)` puts the visible selection back so the user keeps their place. See `CommentComposer.tsx`. - -## Adapting this to your stack - -- **One provider, many components.** Toolbar, sidebar, and review panel all subscribe to the same controller via hooks. They don't pass props down a tree. -- **No design system.** Plain React, plain CSS. Drop the same patterns into Tailwind / shadcn / MUI / Mantine. -- **`modules: { comments: false }` and your own panel.** The demo turns off the built-in comments UI and renders its own. Imported comments still flow through export and import. -- **Capture, then restore.** Composers freeze the selection at open, post on submit, then `restore(capture)` on close. The user sees their range come back instead of typing into a vanished selection. -- **Activity feed merge.** `ActivitySidebar.tsx` interleaves `ui.comments` and `ui.trackChanges` into one panel with about thirty lines of merge logic. The two slices stay separate on the controller so apps that only render one don't pay for the other. - -## What this demo deliberately doesn't do - -- No design system. Patterns over CSS, copy them into yours. -- No backend. The clause library in `` is hardcoded. Real consumers fetch from their own API and call `reg.invalidate()` when permissions or availability change. -- No AI provider. Custom commands can call any LLM from `execute`. The demo picked "Insert clause" because it's concrete and self-contained. -- Telemetry is off (`telemetry: { enabled: false }` in `EditorMount.tsx`) because there's no analytics endpoint to receive events. SuperDoc defaults to enabled. +It now sits under `demos/editor/` to mirror the docs nav (Editor > Custom UI). The workspace is a reference for composing many SuperDoc UI surfaces (toolbar, comments, tracked changes, citations, custom commands, context menus) in one app, including the source-grounded citation flow. diff --git a/demos/docx-from-html/README.md b/demos/docx-from-html/README.md index fa29f456fa..a1db7ffa0d 100644 --- a/demos/docx-from-html/README.md +++ b/demos/docx-from-html/README.md @@ -1,7 +1,5 @@ -# SuperDoc: Init a DOCX from HTML Content +# Moved to demos/editor/superdoc/docx-from-html -An example of initializing SuperDoc with HTML content. +The "init a DOCX from HTML" demo moved to [`demos/editor/superdoc/docx-from-html`](../editor/superdoc/docx-from-html). -This will load a DOCX file (or a blank document), replacing the main contents with the provided HTML. - -In the example we pass `document: sample-document.docx` to load a template with a header and footer. You can omit this key to start with a blank document. +It now sits under `demos/editor/superdoc/` because it teaches an editor-side initialization pattern (passing HTML to ``), not a headless Document API operation. diff --git a/demos/docxtemplater/README.md b/demos/docxtemplater/README.md index 800280b7a0..92ea00d078 100644 --- a/demos/docxtemplater/README.md +++ b/demos/docxtemplater/README.md @@ -1,14 +1,17 @@ -# superdoc-docxtemplater-demo +# Archived: Docxtemplater integration -A demo of SuperDoc and Docxtemplater working together. -![Demo gif](./demo.gif) +This demo is no longer recommended and has been removed from the demo gallery. -[Superdoc homepage](https://superdoc.dev/) | -[Docxtemplater homepage](https://docxtemplater.com/) +## Why archived -# to run -run the following commands in this directory: -- `npm install` -- `npm run dev` +This was a third-party-library integration demo (SuperDoc + Docxtemplater) that pulled a heavy dependency stack (FontAwesome, jQuery, etc.) and was not actively maintained as a supported integration story. -Boilerplate code generated by `create-vue` +## Use instead + +For template merge workflows on top of SuperDoc, prefer: + +- [Document API](https://docs.superdoc.dev/document-api/overview) (`editor.doc.text.rewrite`, `editor.doc.insert`, `editor.doc.contentControls.*`) for programmatic content replacement. +- [Template Builder](https://docs.superdoc.dev/solutions/template-builder/introduction) for an authoring component on top of content controls. +- [`demos/contract-templates`](../contract-templates) for the worked content-controls workflow demo. + +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/editor/custom-ui/README.md b/demos/editor/custom-ui/README.md new file mode 100644 index 0000000000..4aaf6686a9 --- /dev/null +++ b/demos/editor/custom-ui/README.md @@ -0,0 +1,110 @@ +# SuperDoc Custom UI demo + +A reference workspace built on the `superdoc/ui/react` surface. The headline use case is **source-grounded citations**: insert a mock RAG-generated draft, see anchored citation highlights with hover previews, navigate from a sources panel, edit or remove citations. Wrapped in a full editor workspace: custom toolbar, comment threads, tracked-change review, custom commands, and DOCX round-trip. + +See the [Custom UI docs](https://docs.superdoc.dev/editor/custom-ui/overview) for the conceptual guide, and the upcoming [source-grounded citations feature page](https://docs.superdoc.dev/document-api/features/anchored-metadata) for the citation story. + +This demo shows how the pieces compose in a real product, not a single-concept recipe. Read it alongside the docs above when you're wiring your own toolbar, sources panel, or citation overlay. + +## Run + +Prerequisites: Node 20+, pnpm 9+, run from inside the SuperDoc monorepo. + +```bash +pnpm install +pnpm --filter superdoc run build +pnpm --filter @superdoc-dev/react run build +pnpm --filter custom-ui run dev +``` + +Open http://localhost:5189. + +## What you can do here + +- Open the **Sources** tab and click **Insert sample cited draft**. A mocked RAG-generated paragraph is inserted at the end of the document; each cited span is anchored with `editor.doc.metadata.attach` and rendered with a highlight overlay. +- Hover a citation highlight to see the source's display text, locator, provider, and confidence. The popover reads the payload via `ui.viewport.entityAt` + `metadata.get`. +- Click **Scroll to** in the sources panel to navigate to a cited span. Uses `ui.metadata.scrollIntoView({ id })`. +- Click **Edit** on a citation to change `displayText`, `locator`, or `excerpt`. Calls `editor.doc.metadata.update`. +- Click **Remove** to strip the anchor and payload. Calls `editor.doc.metadata.remove`. +- Click toolbar buttons (bold, italic, lists, undo, redo) wired through `useSuperDocCommand`. +- Insert a custom clause registered with `ui.commands.register`. The button works, and so does its keyboard shortcut `Mod-Shift-C`, declared on the registration rather than wired in a separate keydown listener. +- Switch between Edit and Suggest. In Suggest, every edit lands as a tracked change. +- Select text and watch the floating bubble menu appear next to the selection (anchored via `ui.selection.getAnchorRect()`, not `window.getSelection()`). +- Right-click on a tracked change, comment, inside a selection, or on plain text. The menu adapts to the click target: Accept / Reject / Resolve on entities, Copy / Comment on a selection, Insert clause here on plain caret-only text. +- Add a comment. The composer captures the selection on open, posts on submit, and restores the visible range on close so the user keeps their place. +- Accept or reject tracked changes. Decided ones move to a Resolved section. +- Export the doc, edit it in Word, click Import, watch the activity feed update. + +## Source-grounded citations + +The demo composes anchored citation pointers on top of `editor.doc.metadata.*` and `ui.metadata.*`: + +| Layer | What it does | Code | +| --- | --- | --- | +| **Mock RAG output** | Pre-canned text + per-citation payloads (sourceId, displayText, locator, excerpt, confidence). Stand-in for a real generation pipeline. | `mockDraft.ts` | +| **Insert + attach** | Inserts the text via `editor.doc.insert`, then computes a `SelectionTarget` for each cited span and calls `editor.doc.metadata.attach` per citation. | `GenerateDraftButton.tsx`, `useCitations.ts` | +| **Sources panel** | Lists citations grouped by `sourceId`. Scroll-to navigation uses `ui.metadata.scrollIntoView({ id })`. Edit form calls `editor.doc.metadata.update`. | `CitationsPanel.tsx` | +| **Highlight overlay** | Renders one absolute-positioned rectangle per painted line of each cited span. Rects come from `ui.metadata.getRect({ id })`. Remeasures on scroll, resize, ResizeObserver, and MutationObserver. | `CitationHighlights.tsx` | +| **Hover popover** | `ui.viewport.entityAt({ x, y })` returns the content control under the cursor; `metadata.get({ id })` fetches the payload to render. | `CitationPopover.tsx` | +| **Persistence** | Hidden inline content controls in the body carry the stable id in `w:tag`; payloads live in a namespaced custom XML data part. Survives DOCX export, reopen, and Word save (validated by the `word-roundtrip` fixtures in the monorepo). | `editor.doc.metadata.*` | + +`ui.metadata.*` is the supported public surface for consumer-side geometry and navigation; consumers carry the metadata id and never see the SDT node id underneath. + +## Architecture + +``` +SuperDocUIProvider one controller per app +โ””โ”€โ”€ EditorMount + onReady + disableContextMenu + โ”œโ”€โ”€ Toolbar ui.commands + setDocumentMode + โ”œโ”€โ”€ SelectionPopover ui.selection.getAnchorRect, bubble menu over the selection + โ”œโ”€โ”€ ContextMenu ui.viewport.contextAt + ui.commands.getContextMenuItems(context) + item.invoke() + โ”œโ”€โ”€ ContextMenuRegistrations ui.commands.register({ contextMenu: { when } }) + โ”œโ”€โ”€ CitationHighlights ui.metadata.getRect, painted overlay across cited spans + โ”œโ”€โ”€ CitationPopover ui.viewport.entityAt + metadata.get, hover preview + โ””โ”€โ”€ ActivitySidebar ui.comments + ui.trackChanges + ui.selection (Activity tab) + โ”œโ”€โ”€ CitationsPanel editor.doc.metadata.list/get/update/remove + ui.metadata.scrollIntoView (Sources tab) + โ””โ”€โ”€ CommentComposer ui.selection.capture / restore + ui.comments.createFromCapture +``` + +Components consume the controller via `useSuperDocUI()`. They never reach into `editor.state` or `editor.view`. + +## Three surfaces, three subjects + +The demo keeps a strict separation between the three editor UI surfaces. Each one answers a different "what's the subject of this action?" question: + +| Surface | Subject | Items in the demo | +| --- | --- | --- | +| **Toolbar** | The **document** | Bold, Italic, Lists, Undo, Redo, Mode toggle, Insert clause, Export, Import. | +| **Floating bubble menu** | The **selection** | Bold, Italic, Comment on selection. | +| **Right-click context menu** | The **clicked target** | Accept / Reject on tracked change, Resolve on comment, Copy / Comment on selection (when the click is inside the selection rect), Insert clause here (when the click lands on plain caret-only text). | + +`ui.viewport.contextAt({ x, y })` returns one bundle with the click point, the entities under it, the resolved caret position, the live selection, and `insideSelection` (whether the click landed in the painted selection rects). Each predicate filters on the same shape its handler receives, so "Copy" / "Comment on selection" gate themselves on `insideSelection === true` and "Insert clause here" gates on `position !== null && entities.length === 0 && insideSelection !== true`. A stale selection elsewhere on the page can't leak into a right-click somewhere else. + +The `Insert clause here` handler reads `context.position.target` (a collapsed `SelectionTarget` at the click point) and passes it straight to `editor.doc.insert`. The same predicate the menu was filtered with becomes the target the action acts on. Without the bundle, the registration would have to insert against the user's prior selection somewhere else in the doc, making the label a lie. + +Right-click on plain text where no item matches falls through to the browser's native menu. The handler deliberately doesn't `preventDefault` when `getContextMenuItems(context)` returns nothing, so the user gets Copy / Paste / Inspect from the browser instead of a dead right-click. + +## The four custom-UI patterns + +1. **Floating selection toolbar.** `ui.selection.getAnchorRect({ placement: 'start' })` returns viewport-relative coords for the painted selection. Re-position on `useSuperDocSelection()` change plus `scroll`/`resize`. Don't reach for `window.getSelection()`; SuperDoc's painted DOM is separate from the offscreen ProseMirror DOM and the browser API returns the wrong rect. See `SelectionPopover.tsx`. + +2. **Right-click context menu.** Set `disableContextMenu` on `` to suppress the built-in. On `contextmenu`, call `ui.viewport.contextAt({ x, y })` to get the bundle, then `ui.commands.getContextMenuItems(context)` to get items contributed via `register({ contextMenu })`. Each item carries `invoke()`, which fires the registered `execute({ context })` with the bundle bound, so handlers act on the click target without the menu component threading payloads. Scope the listener with `ui.viewport.getHost()` instead of a CSS class. See `ContextMenu.tsx` and `ContextMenuRegistrations.tsx`. + +3. **Custom command + keyboard shortcut.** Declare `shortcut: 'Mod-Shift-C'` on the registration. The controller installs a single bubble-phase keydown listener scoped to the painted host; matched shortcuts dispatch through the same path the toolbar button uses. No per-command keymap wiring. See `InsertClauseButton.tsx`. + +4. **Composer capture + restore.** `ui.selection.capture()` on open holds the selection across focus moves. `ui.comments.createFromCapture(captured, { text })` posts the comment using the frozen target. `ui.selection.restore(captured)` puts the visible selection back so the user keeps their place. See `CommentComposer.tsx`. + +## Adapting this to your stack + +- **One provider, many components.** Toolbar, sidebar, and review panel all subscribe to the same controller via hooks. They don't pass props down a tree. +- **No design system.** Plain React, plain CSS. Drop the same patterns into Tailwind / shadcn / MUI / Mantine. +- **`modules: { comments: false }` and your own panel.** The demo turns off the built-in comments UI and renders its own. Imported comments still flow through export and import. +- **Capture, then restore.** Composers freeze the selection at open, post on submit, then `restore(capture)` on close. The user sees their range come back instead of typing into a vanished selection. +- **Activity feed merge.** `ActivitySidebar.tsx` interleaves `ui.comments` and `ui.trackChanges` into one panel with about thirty lines of merge logic. The two slices stay separate on the controller so apps that only render one don't pay for the other. + +## What this demo deliberately doesn't do + +- No design system. Patterns over CSS, copy them into yours. +- No backend. The clause library in `` is hardcoded. Real consumers fetch from their own API and call `reg.invalidate()` when permissions or availability change. +- No live AI provider. The citation flow uses pre-canned draft text + payloads in `mockDraft.ts` instead of calling an LLM. Real consumers replace this with their RAG output, but the shape that flows into `editor.doc.metadata.attach` (text + cited ranges + payloads) stays the same. +- Telemetry is off (`telemetry: { enabled: false }` in `EditorMount.tsx`) because there's no analytics endpoint to receive events. SuperDoc defaults to enabled. diff --git a/demos/custom-ui/index.html b/demos/editor/custom-ui/index.html similarity index 100% rename from demos/custom-ui/index.html rename to demos/editor/custom-ui/index.html diff --git a/demos/custom-ui/package.json b/demos/editor/custom-ui/package.json similarity index 100% rename from demos/custom-ui/package.json rename to demos/editor/custom-ui/package.json diff --git a/demos/custom-ui/public/sample-review.docx b/demos/editor/custom-ui/public/sample-review.docx similarity index 100% rename from demos/custom-ui/public/sample-review.docx rename to demos/editor/custom-ui/public/sample-review.docx diff --git a/demos/custom-ui/src/App.tsx b/demos/editor/custom-ui/src/App.tsx similarity index 100% rename from demos/custom-ui/src/App.tsx rename to demos/editor/custom-ui/src/App.tsx diff --git a/demos/custom-ui/src/components/ActivitySidebar.tsx b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx similarity index 100% rename from demos/custom-ui/src/components/ActivitySidebar.tsx rename to demos/editor/custom-ui/src/components/ActivitySidebar.tsx diff --git a/demos/custom-ui/src/components/CitationHighlights.tsx b/demos/editor/custom-ui/src/components/CitationHighlights.tsx similarity index 67% rename from demos/custom-ui/src/components/CitationHighlights.tsx rename to demos/editor/custom-ui/src/components/CitationHighlights.tsx index d1323c8b23..cb48330f14 100644 --- a/demos/custom-ui/src/components/CitationHighlights.tsx +++ b/demos/editor/custom-ui/src/components/CitationHighlights.tsx @@ -1,20 +1,20 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ViewportRect } from 'superdoc/ui'; -import { useSuperDocContentControls, useSuperDocUI } from 'superdoc/ui/react'; +import { useSuperDocUI } from 'superdoc/ui/react'; import { useCitations } from './useCitations'; /** * Renders absolute-positioned overlay rectangles on every cited span. * - * Two-step lookup. `editor.doc.metadata.*` keys by the metadata id - * (which is the SDT's `w:tag`); `ui.contentControls.getRect({ id })` - * keys by the SDT's PM node id (which the painter stamps as - * `data-sdt-id`). These are different identifiers. The contentControls - * slice surfaces both per item (`target.nodeId` + `properties.tag`), - * so we build a tag โ†’ nodeId map and translate at measure time. + * Geometry comes from `ui.metadata.getRect({ id })`. The handle hides + * the underlying lookup (metadata id = SDT `w:tag` โ†’ SDT node id โ†’ + * painter rect) so consumers only carry the metadata id they originally + * passed to `editor.doc.metadata.attach`. Before SD-3204 this demo had + * to compose `useSuperDocContentControls` + a tag โ†’ nodeId map + + * `ui.contentControls.getRect`; that bridge is now obviated. * - * `getRect` returns `rects[]` โ€” one ViewportRect per painted line of a - * wrapped span โ€” so line-wrapped citations get clean per-line + * `getRect` returns `rects[]` on success (one ViewportRect per painted + * line of a wrapped span), so line-wrapped citations get clean per-line * underlines without spilling across the page margin. * * Remeasure triggers: window scroll/resize, ResizeObserver on the @@ -28,30 +28,14 @@ import { useCitations } from './useCitations'; */ type HighlightEntry = { metadataId: string; tooltip: string; rects: ViewportRect[] }; -type CCItem = { target?: { nodeId?: string }; properties?: { tag?: string } }; - export function CitationHighlights() { const ui = useSuperDocUI(); const { citations } = useCitations(); - const cc = useSuperDocContentControls(); const [entries, setEntries] = useState([]); - // tag (= metadata id) โ†’ PM node id. Refreshes whenever the slice - // items array reference changes. - const tagToNodeId = useMemo(() => { - const map = new Map(); - for (const item of (cc.items ?? []) as unknown as CCItem[]) { - const tag = item.properties?.tag; - const nodeId = item.target?.nodeId; - if (typeof tag === 'string' && typeof nodeId === 'string') { - map.set(tag, nodeId); - } - } - return map; - }, [cc.items]); - useEffect(() => { - if (!ui) { + const metadata = ui?.metadata; + if (!metadata?.getRect) { setEntries([]); return; } @@ -59,9 +43,7 @@ export function CitationHighlights() { const remeasure = () => { const next: HighlightEntry[] = []; for (const c of citations) { - const nodeId = tagToNodeId.get(c.id); - if (!nodeId) continue; - const result = ui.contentControls.getRect({ id: nodeId }); + const result = metadata.getRect({ id: c.id }); if (!result.success) continue; next.push({ metadataId: c.id, @@ -91,8 +73,8 @@ export function CitationHighlights() { const resizeObserver = canvas ? new ResizeObserver(scheduleRemeasure) : null; if (canvas && resizeObserver) resizeObserver.observe(canvas); - // Skip the DOM-mutation observer when there are no citations to track โ€” - // keeps the demo from observing the editor body when there's nothing to update. + // Skip the DOM-mutation observer when there are no citations to track, + // so the demo doesn't observe the editor body when there's nothing to update. const mutationObserver = canvas && citations.length > 0 ? new MutationObserver(scheduleRemeasure) @@ -108,7 +90,7 @@ export function CitationHighlights() { mutationObserver?.disconnect(); if (rafHandle !== null) cancelAnimationFrame(rafHandle); }; - }, [ui, citations, tagToNodeId]); + }, [ui, citations]); return (
diff --git a/demos/custom-ui/src/components/CitationPopover.tsx b/demos/editor/custom-ui/src/components/CitationPopover.tsx similarity index 100% rename from demos/custom-ui/src/components/CitationPopover.tsx rename to demos/editor/custom-ui/src/components/CitationPopover.tsx diff --git a/demos/custom-ui/src/components/CitationsPanel.tsx b/demos/editor/custom-ui/src/components/CitationsPanel.tsx similarity index 86% rename from demos/custom-ui/src/components/CitationsPanel.tsx rename to demos/editor/custom-ui/src/components/CitationsPanel.tsx index 5500b41b26..10d2757bbc 100644 --- a/demos/custom-ui/src/components/CitationsPanel.tsx +++ b/demos/editor/custom-ui/src/components/CitationsPanel.tsx @@ -1,31 +1,33 @@ import { useMemo, useState } from 'react'; import { useSuperDocUI } from 'superdoc/ui/react'; -import { selectionTargetToTextTarget, type CitationInfo, type CitationPayload } from './citations-types'; +import type { CitationInfo, CitationPayload } from './citations-types'; import { useCitations } from './useCitations'; import { GenerateDraftButton } from './GenerateDraftButton'; type UpdateCitation = (id: string, payload: CitationPayload) => { error?: string }; /** - * References panel. Renders citations grouped by `sourceId` โ€” the - * pattern Harvey / CoCounsel / Lexis+ use for the side panel beside - * AI-generated output. Each source group shows the metadata and links - * back to every cited span in the body. No manual composer; citations - * arrive via `metadata.attach` from the mocked generation pipeline. + * References panel. Renders citations grouped by `sourceId`, the + * pattern legal-AI products use for the side panel beside generated + * output. Each source group shows the metadata and links back to + * every cited span in the body. No manual composer; citations arrive + * via `metadata.attach` from the mocked generation pipeline. */ export function CitationsPanel() { const ui = useSuperDocUI(); - const { citations, resolve, remove, update, loading } = useCitations(); + const { citations, remove, update, loading } = useCitations(); const [editingId, setEditingId] = useState(null); const groups = useMemo(() => groupBySource(citations), [citations]); + // Single-call navigation: `ui.metadata.scrollIntoView` resolves the + // metadata id to its anchor range internally. Before SD-3204 the demo + // had to call `metadata.resolve` and convert SelectionTarget to + // TextTarget by hand before calling `ui.viewport.scrollIntoView`. const scrollTo = async (id: string) => { - if (!ui) return; - const selectionTarget = resolve(id); - const textTarget = selectionTargetToTextTarget(selectionTarget); - if (!textTarget) return; - await ui.viewport.scrollIntoView({ target: textTarget }); + const metadata = ui?.metadata; + if (!metadata?.scrollIntoView) return; + await metadata.scrollIntoView({ id, block: 'center' }); }; return ( @@ -120,7 +122,7 @@ function groupBySource(citations: CitationInfo[]): ReferenceGroup[] { } /** - * Inline edit form. Exercises `metadata.update` โ€” the lawyer can fix + * Inline edit form. Exercises `metadata.update` so the lawyer can fix * displayText, locator, or excerpt without re-running the generation * pipeline. `citationId`, `sourceId`, `sourceType`, and `provider` are * locked here because changing those would mean a different citation, @@ -130,7 +132,7 @@ function groupBySource(citations: CitationInfo[]): ReferenceGroup[] { * `update` is passed from the parent `CitationsPanel` rather than read * via a child-local `useCitations()`. A payload-only `metadata.update` * does not change the SDT structure, so the parent's content-controls - * slice does not tick โ€” a child-local hook would only refresh the + * slice does not tick; a child-local hook would only refresh the * child's own copy of `citations`, leaving the parent panel stale on * Save. */ diff --git a/demos/custom-ui/src/components/CommentComposer.tsx b/demos/editor/custom-ui/src/components/CommentComposer.tsx similarity index 100% rename from demos/custom-ui/src/components/CommentComposer.tsx rename to demos/editor/custom-ui/src/components/CommentComposer.tsx diff --git a/demos/custom-ui/src/components/ContextMenu.tsx b/demos/editor/custom-ui/src/components/ContextMenu.tsx similarity index 100% rename from demos/custom-ui/src/components/ContextMenu.tsx rename to demos/editor/custom-ui/src/components/ContextMenu.tsx diff --git a/demos/custom-ui/src/components/ContextMenuRegistrations.tsx b/demos/editor/custom-ui/src/components/ContextMenuRegistrations.tsx similarity index 100% rename from demos/custom-ui/src/components/ContextMenuRegistrations.tsx rename to demos/editor/custom-ui/src/components/ContextMenuRegistrations.tsx diff --git a/demos/custom-ui/src/components/DisplaySettings.tsx b/demos/editor/custom-ui/src/components/DisplaySettings.tsx similarity index 100% rename from demos/custom-ui/src/components/DisplaySettings.tsx rename to demos/editor/custom-ui/src/components/DisplaySettings.tsx diff --git a/demos/custom-ui/src/components/GenerateDraftButton.tsx b/demos/editor/custom-ui/src/components/GenerateDraftButton.tsx similarity index 100% rename from demos/custom-ui/src/components/GenerateDraftButton.tsx rename to demos/editor/custom-ui/src/components/GenerateDraftButton.tsx diff --git a/demos/custom-ui/src/components/InsertClauseButton.tsx b/demos/editor/custom-ui/src/components/InsertClauseButton.tsx similarity index 100% rename from demos/custom-ui/src/components/InsertClauseButton.tsx rename to demos/editor/custom-ui/src/components/InsertClauseButton.tsx diff --git a/demos/custom-ui/src/components/SelectionPopover.tsx b/demos/editor/custom-ui/src/components/SelectionPopover.tsx similarity index 100% rename from demos/custom-ui/src/components/SelectionPopover.tsx rename to demos/editor/custom-ui/src/components/SelectionPopover.tsx diff --git a/demos/custom-ui/src/components/Toolbar.tsx b/demos/editor/custom-ui/src/components/Toolbar.tsx similarity index 100% rename from demos/custom-ui/src/components/Toolbar.tsx rename to demos/editor/custom-ui/src/components/Toolbar.tsx diff --git a/demos/custom-ui/src/components/citations-types.ts b/demos/editor/custom-ui/src/components/citations-types.ts similarity index 100% rename from demos/custom-ui/src/components/citations-types.ts rename to demos/editor/custom-ui/src/components/citations-types.ts diff --git a/demos/custom-ui/src/components/mockDraft.ts b/demos/editor/custom-ui/src/components/mockDraft.ts similarity index 100% rename from demos/custom-ui/src/components/mockDraft.ts rename to demos/editor/custom-ui/src/components/mockDraft.ts diff --git a/demos/custom-ui/src/components/useCitations.ts b/demos/editor/custom-ui/src/components/useCitations.ts similarity index 100% rename from demos/custom-ui/src/components/useCitations.ts rename to demos/editor/custom-ui/src/components/useCitations.ts diff --git a/demos/custom-ui/src/components/useDecidedChanges.ts b/demos/editor/custom-ui/src/components/useDecidedChanges.ts similarity index 100% rename from demos/custom-ui/src/components/useDecidedChanges.ts rename to demos/editor/custom-ui/src/components/useDecidedChanges.ts diff --git a/demos/custom-ui/src/editor/EditorMount.tsx b/demos/editor/custom-ui/src/editor/EditorMount.tsx similarity index 100% rename from demos/custom-ui/src/editor/EditorMount.tsx rename to demos/editor/custom-ui/src/editor/EditorMount.tsx diff --git a/demos/custom-ui/src/main.tsx b/demos/editor/custom-ui/src/main.tsx similarity index 100% rename from demos/custom-ui/src/main.tsx rename to demos/editor/custom-ui/src/main.tsx diff --git a/demos/custom-ui/src/styles.css b/demos/editor/custom-ui/src/styles.css similarity index 100% rename from demos/custom-ui/src/styles.css rename to demos/editor/custom-ui/src/styles.css diff --git a/demos/custom-ui/tsconfig.json b/demos/editor/custom-ui/tsconfig.json similarity index 100% rename from demos/custom-ui/tsconfig.json rename to demos/editor/custom-ui/tsconfig.json diff --git a/demos/custom-ui/vite.config.ts b/demos/editor/custom-ui/vite.config.ts similarity index 100% rename from demos/custom-ui/vite.config.ts rename to demos/editor/custom-ui/vite.config.ts diff --git a/demos/chrome-extension/chrome-extension/README.md b/demos/editor/integrations/chrome-extension/chrome-extension/README.md similarity index 100% rename from demos/chrome-extension/chrome-extension/README.md rename to demos/editor/integrations/chrome-extension/chrome-extension/README.md diff --git a/demos/chrome-extension/chrome-extension/background.js b/demos/editor/integrations/chrome-extension/chrome-extension/background.js similarity index 100% rename from demos/chrome-extension/chrome-extension/background.js rename to demos/editor/integrations/chrome-extension/chrome-extension/background.js diff --git a/demos/chrome-extension/chrome-extension/content.js b/demos/editor/integrations/chrome-extension/chrome-extension/content.js similarity index 100% rename from demos/chrome-extension/chrome-extension/content.js rename to demos/editor/integrations/chrome-extension/chrome-extension/content.js diff --git a/demos/chrome-extension/chrome-extension/dist/docx-validator.bundle.js b/demos/editor/integrations/chrome-extension/chrome-extension/dist/docx-validator.bundle.js similarity index 100% rename from demos/chrome-extension/chrome-extension/dist/docx-validator.bundle.js rename to demos/editor/integrations/chrome-extension/chrome-extension/dist/docx-validator.bundle.js diff --git a/demos/chrome-extension/chrome-extension/dist/docx-validator.bundle.js.LICENSE.txt b/demos/editor/integrations/chrome-extension/chrome-extension/dist/docx-validator.bundle.js.LICENSE.txt similarity index 100% rename from demos/chrome-extension/chrome-extension/dist/docx-validator.bundle.js.LICENSE.txt rename to demos/editor/integrations/chrome-extension/chrome-extension/dist/docx-validator.bundle.js.LICENSE.txt diff --git a/demos/chrome-extension/chrome-extension/docx-validator.js b/demos/editor/integrations/chrome-extension/chrome-extension/docx-validator.js similarity index 100% rename from demos/chrome-extension/chrome-extension/docx-validator.js rename to demos/editor/integrations/chrome-extension/chrome-extension/docx-validator.js diff --git a/demos/chrome-extension/chrome-extension/icons/icon-128x128-disabled.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-128x128-disabled.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-128x128-disabled.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-128x128-disabled.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-128x128.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-128x128.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-128x128.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-128x128.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-16x16-disabled.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-16x16-disabled.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-16x16-disabled.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-16x16-disabled.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-16x16.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-16x16.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-16x16.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-16x16.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-19x19-disabled.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-19x19-disabled.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-19x19-disabled.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-19x19-disabled.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-19x19.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-19x19.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-19x19.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-19x19.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-48x48-disabled.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-48x48-disabled.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-48x48-disabled.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-48x48-disabled.png diff --git a/demos/chrome-extension/chrome-extension/icons/icon-48x48.png b/demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-48x48.png similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/icon-48x48.png rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/icon-48x48.png diff --git a/demos/chrome-extension/chrome-extension/icons/logo.webp b/demos/editor/integrations/chrome-extension/chrome-extension/icons/logo.webp similarity index 100% rename from demos/chrome-extension/chrome-extension/icons/logo.webp rename to demos/editor/integrations/chrome-extension/chrome-extension/icons/logo.webp diff --git a/demos/chrome-extension/chrome-extension/lib/style.css b/demos/editor/integrations/chrome-extension/chrome-extension/lib/style.css similarity index 100% rename from demos/chrome-extension/chrome-extension/lib/style.css rename to demos/editor/integrations/chrome-extension/chrome-extension/lib/style.css diff --git a/demos/chrome-extension/chrome-extension/lib/superdoc.min.js b/demos/editor/integrations/chrome-extension/chrome-extension/lib/superdoc.min.js similarity index 100% rename from demos/chrome-extension/chrome-extension/lib/superdoc.min.js rename to demos/editor/integrations/chrome-extension/chrome-extension/lib/superdoc.min.js diff --git a/demos/chrome-extension/chrome-extension/manifest.json b/demos/editor/integrations/chrome-extension/chrome-extension/manifest.json similarity index 100% rename from demos/chrome-extension/chrome-extension/manifest.json rename to demos/editor/integrations/chrome-extension/chrome-extension/manifest.json diff --git a/demos/chrome-extension/chrome-extension/modal.css b/demos/editor/integrations/chrome-extension/chrome-extension/modal.css similarity index 100% rename from demos/chrome-extension/chrome-extension/modal.css rename to demos/editor/integrations/chrome-extension/chrome-extension/modal.css diff --git a/demos/chrome-extension/chrome-extension/modal.html b/demos/editor/integrations/chrome-extension/chrome-extension/modal.html similarity index 100% rename from demos/chrome-extension/chrome-extension/modal.html rename to demos/editor/integrations/chrome-extension/chrome-extension/modal.html diff --git a/demos/chrome-extension/chrome-extension/package.json b/demos/editor/integrations/chrome-extension/chrome-extension/package.json similarity index 100% rename from demos/chrome-extension/chrome-extension/package.json rename to demos/editor/integrations/chrome-extension/chrome-extension/package.json diff --git a/demos/chrome-extension/chrome-extension/popup.html b/demos/editor/integrations/chrome-extension/chrome-extension/popup.html similarity index 100% rename from demos/chrome-extension/chrome-extension/popup.html rename to demos/editor/integrations/chrome-extension/chrome-extension/popup.html diff --git a/demos/chrome-extension/chrome-extension/popup.js b/demos/editor/integrations/chrome-extension/chrome-extension/popup.js similarity index 100% rename from demos/chrome-extension/chrome-extension/popup.js rename to demos/editor/integrations/chrome-extension/chrome-extension/popup.js diff --git a/demos/chrome-extension/chrome-extension/test_docs/Lunch Haiku (5).docx b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Lunch Haiku (5).docx similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/Lunch Haiku (5).docx rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Lunch Haiku (5).docx diff --git a/demos/chrome-extension/chrome-extension/test_docs/Mutual NDA_draft (1).docx b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Mutual NDA_draft (1).docx similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/Mutual NDA_draft (1).docx rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Mutual NDA_draft (1).docx diff --git a/demos/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc MS WORD.docx b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc MS WORD.docx similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc MS WORD.docx rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc MS WORD.docx diff --git a/demos/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc.docx b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc.docx similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc.docx rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/Nda Formatted Doc.docx diff --git a/demos/chrome-extension/chrome-extension/test_docs/nda_formatted_doc (1).md b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/nda_formatted_doc (1).md similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/nda_formatted_doc (1).md rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/nda_formatted_doc (1).md diff --git a/demos/chrome-extension/chrome-extension/test_docs/sdpr (23).docx b/demos/editor/integrations/chrome-extension/chrome-extension/test_docs/sdpr (23).docx similarity index 100% rename from demos/chrome-extension/chrome-extension/test_docs/sdpr (23).docx rename to demos/editor/integrations/chrome-extension/chrome-extension/test_docs/sdpr (23).docx diff --git a/demos/chrome-extension/chrome-extension/tester.html b/demos/editor/integrations/chrome-extension/chrome-extension/tester.html similarity index 100% rename from demos/chrome-extension/chrome-extension/tester.html rename to demos/editor/integrations/chrome-extension/chrome-extension/tester.html diff --git a/demos/chrome-extension/chrome-extension/webpack.config.js b/demos/editor/integrations/chrome-extension/chrome-extension/webpack.config.js similarity index 100% rename from demos/chrome-extension/chrome-extension/webpack.config.js rename to demos/editor/integrations/chrome-extension/chrome-extension/webpack.config.js diff --git a/demos/chrome-extension/demo-config.json b/demos/editor/integrations/chrome-extension/demo-config.json similarity index 100% rename from demos/chrome-extension/demo-config.json rename to demos/editor/integrations/chrome-extension/demo-config.json diff --git a/demos/chrome-extension/demo-thumbnail.png b/demos/editor/integrations/chrome-extension/demo-thumbnail.png similarity index 100% rename from demos/chrome-extension/demo-thumbnail.png rename to demos/editor/integrations/chrome-extension/demo-thumbnail.png diff --git a/demos/chrome-extension/demo-video.mp4 b/demos/editor/integrations/chrome-extension/demo-video.mp4 similarity index 100% rename from demos/chrome-extension/demo-video.mp4 rename to demos/editor/integrations/chrome-extension/demo-video.mp4 diff --git a/demos/word-addin/.gitignore b/demos/editor/integrations/word-addin/.gitignore similarity index 100% rename from demos/word-addin/.gitignore rename to demos/editor/integrations/word-addin/.gitignore diff --git a/demos/word-addin/MS-Word-Add-in-Sample.code-workspace b/demos/editor/integrations/word-addin/MS-Word-Add-in-Sample.code-workspace similarity index 100% rename from demos/word-addin/MS-Word-Add-in-Sample.code-workspace rename to demos/editor/integrations/word-addin/MS-Word-Add-in-Sample.code-workspace diff --git a/demos/editor/integrations/word-addin/README.md b/demos/editor/integrations/word-addin/README.md new file mode 100644 index 0000000000..0208008a31 --- /dev/null +++ b/demos/editor/integrations/word-addin/README.md @@ -0,0 +1,165 @@ +# SuperDoc MS Add-in Sync + +Real-time document synchronization between Microsoft Word Add-in and web editor using WebSocket communication. + +## Architecture + +The system consists of three main components: +- **MS Word Add-in** (`src/taskpane/taskpane.js`) - Runs inside Microsoft Word +- **Web Editor** (`server/public/editor.js`) - Browser-based document editor +- **Node.js Server** (`server/server.js`) - WebSocket server handling real-time sync + +## WebSocket Events + +The WebSocket communication uses the following event types: + +### `client_ready` +**Sent by:** Web Editor +**Handled by:** Server (broadcasts to other clients) +**Purpose:** Signals that a browser client has loaded and is ready to receive authentication + +```javascript +// Sent by editor.js +websocket.send(JSON.stringify({ + type: 'client_ready' +})); + +// Broadcasted by server.js to other clients +{ + type: 'client_ready', + timestamp: '2024-01-01T00:00:00.000Z' +} +``` + +### `token_transfer` +**Sent by:** MS Word Add-in +**Handled by:** Server (validates and broadcasts to other clients), Web Editor +**Purpose:** Transfers authentication token from Word add-in to web editor + +```javascript +// Sent by taskpane.js +websocket.send(JSON.stringify({ + type: 'token_transfer', + token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...' +})); + +// Broadcasted by server.js after validation +{ + type: 'token_transfer', + token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...', + user: { + email: 'user@example.com', + name: 'User Name', + picture: 'https://example.com/avatar.jpg' + }, + timestamp: '2024-01-01T00:00:00.000Z' +} + +// Error response +{ + type: 'token_transfer', + error: 'Invalid token', + timestamp: '2024-01-01T00:00:00.000Z' +} +``` + +### `document_update` +**Sent by:** MS Word Add-in, Web Editor +**Handled by:** Server (validates and broadcasts to other clients), MS Word Add-in, Web Editor +**Purpose:** Real-time document synchronization between clients + +```javascript +// Sent by taskpane.js or editor.js +websocket.send(JSON.stringify({ + type: 'document_update', + token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...', + document: 'UEsDBBQABgAIAAAAIQDb4fbL7...' // Base64 encoded .docx +})); + +// Broadcasted by server.js after validation +{ + type: 'document_update', + document: 'UEsDBBQABgAIAAAAIQDb4fbL7...', + author: 'user@example.com', + timestamp: '2024-01-01T00:00:00.000Z' +} +``` + +### `close` +**Sent by:** Server +**Handled by:** MS Word Add-in, Web Editor +**Purpose:** Notifies clients when another connection closes + +```javascript +// Broadcasted by server.js +{ + type: 'close', + timestamp: '2024-01-01T00:00:00.000Z' +} +``` + +### `error` +**Sent by:** Server +**Handled by:** MS Word Add-in, Web Editor +**Purpose:** Notifies clients when a connection error occurs + +```javascript +// Broadcasted by server.js +{ + type: 'error', + timestamp: '2024-01-01T00:00:00.000Z' +} +``` + +## Authentication Flow + +1. Web editor loads and sends `client_ready` to server +2. Server broadcasts `client_ready` to Word add-in +3. Word add-in sends `token_transfer` with user's authentication token +4. Server validates token against Auth0 and broadcasts `token_transfer` with user info to web editor +5. Both clients are now authenticated and can send `document_update` events + +## Real-time Synchronization + +- Document changes in Word trigger `document_update` events via selection change detection +- Document changes in web editor trigger `document_update` events via SuperDoc's `onEditorUpdate` callback +- All `document_update` events include the full document as Base64-encoded .docx +- Server validates authentication token before broadcasting updates +- Clients update their document content when receiving `document_update` events + +## Setup + +1. Install dependencies: + ```bash + npm install + cd server && npm install + ``` + +2. Configure environment variables: + + **Auth0 Configuration** - Set up `src/auth0-config.js`: + - Get your Auth0 domain, client ID, and audience from your [Auth0 Dashboard](https://manage.auth0.com/) + - Create a new application or use an existing Single Page Application + - Configure the redirect URLs to include your add-in's domain + + **Server Configuration** - Set up `server/.env`: + - `AUTH0_DOMAIN`: Your Auth0 domain (e.g., `yourapp.us.auth0.com`) + - `AUTH0_AUDIENCE`: Your Auth0 API identifier + - Any additional environment variables required by your cloud function + + These environment variables are required for: + - **Auth0**: Authenticating users and validating JWT tokens + - **Cloud Function**: Server-side token validation and WebSocket communication + + You can reference the example files in the same directories. + +3. Start the server: + ```bash + npm run server + ``` + +4. Build and run the add-in: + ```bash + npm run build + npm start + ``` \ No newline at end of file diff --git a/demos/word-addin/assets/icon-128.png b/demos/editor/integrations/word-addin/assets/icon-128.png similarity index 100% rename from demos/word-addin/assets/icon-128.png rename to demos/editor/integrations/word-addin/assets/icon-128.png diff --git a/demos/word-addin/assets/icon-128x128.png b/demos/editor/integrations/word-addin/assets/icon-128x128.png similarity index 100% rename from demos/word-addin/assets/icon-128x128.png rename to demos/editor/integrations/word-addin/assets/icon-128x128.png diff --git a/demos/word-addin/assets/icon-16.png b/demos/editor/integrations/word-addin/assets/icon-16.png similarity index 100% rename from demos/word-addin/assets/icon-16.png rename to demos/editor/integrations/word-addin/assets/icon-16.png diff --git a/demos/word-addin/assets/icon-16x16.png b/demos/editor/integrations/word-addin/assets/icon-16x16.png similarity index 100% rename from demos/word-addin/assets/icon-16x16.png rename to demos/editor/integrations/word-addin/assets/icon-16x16.png diff --git a/demos/word-addin/assets/icon-32.png b/demos/editor/integrations/word-addin/assets/icon-32.png similarity index 100% rename from demos/word-addin/assets/icon-32.png rename to demos/editor/integrations/word-addin/assets/icon-32.png diff --git a/demos/word-addin/assets/icon-32x32.png b/demos/editor/integrations/word-addin/assets/icon-32x32.png similarity index 100% rename from demos/word-addin/assets/icon-32x32.png rename to demos/editor/integrations/word-addin/assets/icon-32x32.png diff --git a/demos/word-addin/assets/icon-64.png b/demos/editor/integrations/word-addin/assets/icon-64.png similarity index 100% rename from demos/word-addin/assets/icon-64.png rename to demos/editor/integrations/word-addin/assets/icon-64.png diff --git a/demos/word-addin/assets/icon-64x64.png b/demos/editor/integrations/word-addin/assets/icon-64x64.png similarity index 100% rename from demos/word-addin/assets/icon-64x64.png rename to demos/editor/integrations/word-addin/assets/icon-64x64.png diff --git a/demos/word-addin/assets/icon-80.png b/demos/editor/integrations/word-addin/assets/icon-80.png similarity index 100% rename from demos/word-addin/assets/icon-80.png rename to demos/editor/integrations/word-addin/assets/icon-80.png diff --git a/demos/word-addin/assets/icon-80x80.png b/demos/editor/integrations/word-addin/assets/icon-80x80.png similarity index 100% rename from demos/word-addin/assets/icon-80x80.png rename to demos/editor/integrations/word-addin/assets/icon-80x80.png diff --git a/demos/word-addin/assets/logo-filled.png b/demos/editor/integrations/word-addin/assets/logo-filled.png similarity index 100% rename from demos/word-addin/assets/logo-filled.png rename to demos/editor/integrations/word-addin/assets/logo-filled.png diff --git a/demos/word-addin/assets/logo.png b/demos/editor/integrations/word-addin/assets/logo.png similarity index 100% rename from demos/word-addin/assets/logo.png rename to demos/editor/integrations/word-addin/assets/logo.png diff --git a/demos/word-addin/assets/sample-document.docx b/demos/editor/integrations/word-addin/assets/sample-document.docx similarity index 100% rename from demos/word-addin/assets/sample-document.docx rename to demos/editor/integrations/word-addin/assets/sample-document.docx diff --git a/demos/word-addin/babel.config.json b/demos/editor/integrations/word-addin/babel.config.json similarity index 100% rename from demos/word-addin/babel.config.json rename to demos/editor/integrations/word-addin/babel.config.json diff --git a/demos/word-addin/demo-config.json b/demos/editor/integrations/word-addin/demo-config.json similarity index 100% rename from demos/word-addin/demo-config.json rename to demos/editor/integrations/word-addin/demo-config.json diff --git a/demos/word-addin/demo-thumbnail.png b/demos/editor/integrations/word-addin/demo-thumbnail.png similarity index 100% rename from demos/word-addin/demo-thumbnail.png rename to demos/editor/integrations/word-addin/demo-thumbnail.png diff --git a/demos/word-addin/demo-video.mp4 b/demos/editor/integrations/word-addin/demo-video.mp4 similarity index 100% rename from demos/word-addin/demo-video.mp4 rename to demos/editor/integrations/word-addin/demo-video.mp4 diff --git a/demos/word-addin/manifest.xml b/demos/editor/integrations/word-addin/manifest.xml similarity index 100% rename from demos/word-addin/manifest.xml rename to demos/editor/integrations/word-addin/manifest.xml diff --git a/demos/word-addin/package.json b/demos/editor/integrations/word-addin/package.json similarity index 100% rename from demos/word-addin/package.json rename to demos/editor/integrations/word-addin/package.json diff --git a/demos/word-addin/server/.env.example b/demos/editor/integrations/word-addin/server/.env.example similarity index 100% rename from demos/word-addin/server/.env.example rename to demos/editor/integrations/word-addin/server/.env.example diff --git a/demos/word-addin/server/package.json b/demos/editor/integrations/word-addin/server/package.json similarity index 100% rename from demos/word-addin/server/package.json rename to demos/editor/integrations/word-addin/server/package.json diff --git a/demos/word-addin/server/public/editor.css b/demos/editor/integrations/word-addin/server/public/editor.css similarity index 100% rename from demos/word-addin/server/public/editor.css rename to demos/editor/integrations/word-addin/server/public/editor.css diff --git a/demos/word-addin/server/public/editor.html b/demos/editor/integrations/word-addin/server/public/editor.html similarity index 100% rename from demos/word-addin/server/public/editor.html rename to demos/editor/integrations/word-addin/server/public/editor.html diff --git a/demos/word-addin/server/public/editor.js b/demos/editor/integrations/word-addin/server/public/editor.js similarity index 100% rename from demos/word-addin/server/public/editor.js rename to demos/editor/integrations/word-addin/server/public/editor.js diff --git a/demos/word-addin/server/server.js b/demos/editor/integrations/word-addin/server/server.js similarity index 100% rename from demos/word-addin/server/server.js rename to demos/editor/integrations/word-addin/server/server.js diff --git a/demos/word-addin/src/auth-dialog/auth-dialog.css b/demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.css similarity index 100% rename from demos/word-addin/src/auth-dialog/auth-dialog.css rename to demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.css diff --git a/demos/word-addin/src/auth-dialog/auth-dialog.html b/demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.html similarity index 100% rename from demos/word-addin/src/auth-dialog/auth-dialog.html rename to demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.html diff --git a/demos/word-addin/src/auth-dialog/auth-dialog.js b/demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.js similarity index 100% rename from demos/word-addin/src/auth-dialog/auth-dialog.js rename to demos/editor/integrations/word-addin/src/auth-dialog/auth-dialog.js diff --git a/demos/word-addin/src/auth0-config.js.example b/demos/editor/integrations/word-addin/src/auth0-config.js.example similarity index 100% rename from demos/word-addin/src/auth0-config.js.example rename to demos/editor/integrations/word-addin/src/auth0-config.js.example diff --git a/demos/word-addin/src/server-domain.js.example b/demos/editor/integrations/word-addin/src/server-domain.js.example similarity index 100% rename from demos/word-addin/src/server-domain.js.example rename to demos/editor/integrations/word-addin/src/server-domain.js.example diff --git a/demos/word-addin/src/taskpane/taskpane.css b/demos/editor/integrations/word-addin/src/taskpane/taskpane.css similarity index 100% rename from demos/word-addin/src/taskpane/taskpane.css rename to demos/editor/integrations/word-addin/src/taskpane/taskpane.css diff --git a/demos/word-addin/src/taskpane/taskpane.html b/demos/editor/integrations/word-addin/src/taskpane/taskpane.html similarity index 100% rename from demos/word-addin/src/taskpane/taskpane.html rename to demos/editor/integrations/word-addin/src/taskpane/taskpane.html diff --git a/demos/word-addin/src/taskpane/taskpane.js b/demos/editor/integrations/word-addin/src/taskpane/taskpane.js similarity index 100% rename from demos/word-addin/src/taskpane/taskpane.js rename to demos/editor/integrations/word-addin/src/taskpane/taskpane.js diff --git a/demos/word-addin/webpack.config.js b/demos/editor/integrations/word-addin/webpack.config.js similarity index 100% rename from demos/word-addin/webpack.config.js rename to demos/editor/integrations/word-addin/webpack.config.js diff --git a/demos/docx-from-html/.gitignore b/demos/editor/superdoc/docx-from-html/.gitignore similarity index 100% rename from demos/docx-from-html/.gitignore rename to demos/editor/superdoc/docx-from-html/.gitignore diff --git a/demos/editor/superdoc/docx-from-html/README.md b/demos/editor/superdoc/docx-from-html/README.md new file mode 100644 index 0000000000..fa29f456fa --- /dev/null +++ b/demos/editor/superdoc/docx-from-html/README.md @@ -0,0 +1,7 @@ +# SuperDoc: Init a DOCX from HTML Content + +An example of initializing SuperDoc with HTML content. + +This will load a DOCX file (or a blank document), replacing the main contents with the provided HTML. + +In the example we pass `document: sample-document.docx` to load a template with a header and footer. You can omit this key to start with a blank document. diff --git a/demos/docx-from-html/demo-config.json b/demos/editor/superdoc/docx-from-html/demo-config.json similarity index 100% rename from demos/docx-from-html/demo-config.json rename to demos/editor/superdoc/docx-from-html/demo-config.json diff --git a/demos/docx-from-html/demo-thumbnail.png b/demos/editor/superdoc/docx-from-html/demo-thumbnail.png similarity index 100% rename from demos/docx-from-html/demo-thumbnail.png rename to demos/editor/superdoc/docx-from-html/demo-thumbnail.png diff --git a/demos/docx-from-html/demo-video.mp4 b/demos/editor/superdoc/docx-from-html/demo-video.mp4 similarity index 100% rename from demos/docx-from-html/demo-video.mp4 rename to demos/editor/superdoc/docx-from-html/demo-video.mp4 diff --git a/demos/docx-from-html/index.html b/demos/editor/superdoc/docx-from-html/index.html similarity index 100% rename from demos/docx-from-html/index.html rename to demos/editor/superdoc/docx-from-html/index.html diff --git a/demos/docx-from-html/package.json b/demos/editor/superdoc/docx-from-html/package.json similarity index 100% rename from demos/docx-from-html/package.json rename to demos/editor/superdoc/docx-from-html/package.json diff --git a/demos/docx-from-html/public/logo.webp b/demos/editor/superdoc/docx-from-html/public/logo.webp similarity index 100% rename from demos/docx-from-html/public/logo.webp rename to demos/editor/superdoc/docx-from-html/public/logo.webp diff --git a/demos/docx-from-html/public/sample-document.docx b/demos/editor/superdoc/docx-from-html/public/sample-document.docx similarity index 100% rename from demos/docx-from-html/public/sample-document.docx rename to demos/editor/superdoc/docx-from-html/public/sample-document.docx diff --git a/demos/docx-from-html/public/superdoc-logo.png b/demos/editor/superdoc/docx-from-html/public/superdoc-logo.png similarity index 100% rename from demos/docx-from-html/public/superdoc-logo.png rename to demos/editor/superdoc/docx-from-html/public/superdoc-logo.png diff --git a/demos/docx-from-html/src/App.vue b/demos/editor/superdoc/docx-from-html/src/App.vue similarity index 100% rename from demos/docx-from-html/src/App.vue rename to demos/editor/superdoc/docx-from-html/src/App.vue diff --git a/demos/docx-from-html/src/main.js b/demos/editor/superdoc/docx-from-html/src/main.js similarity index 100% rename from demos/docx-from-html/src/main.js rename to demos/editor/superdoc/docx-from-html/src/main.js diff --git a/demos/docx-from-html/src/style.css b/demos/editor/superdoc/docx-from-html/src/style.css similarity index 100% rename from demos/docx-from-html/src/style.css rename to demos/editor/superdoc/docx-from-html/src/style.css diff --git a/demos/docx-from-html/vite.config.js b/demos/editor/superdoc/docx-from-html/vite.config.js similarity index 100% rename from demos/docx-from-html/vite.config.js rename to demos/editor/superdoc/docx-from-html/vite.config.js diff --git a/demos/loading-from-json/README.md b/demos/loading-from-json/README.md new file mode 100644 index 0000000000..8d7274dceb --- /dev/null +++ b/demos/loading-from-json/README.md @@ -0,0 +1,14 @@ +# Archived: loading editor JSON + +This demo is no longer recommended and has been removed from the demo gallery. + +## Why archived + +There is no public `editor.loadJSON()` API. The supported path for providing initial document state from JSON is the `jsonOverride` option passed to `SuperDoc` at construction time. This demo predates that surface and never had a README. + +## Use instead + +- The `jsonOverride` option on `SuperDoc`, set at init time. Documented under the SuperDoc configuration reference. +- For inserting JSON content into an existing document, `editor.doc.insert` with the structural insert input shape. + +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/manifest.json b/demos/manifest.json index 80b9dd8d9b..a825686e7e 100644 --- a/demos/manifest.json +++ b/demos/manifest.json @@ -1,6 +1,11 @@ [ { "id": "contract-templates", + "section": "solutions", + "subsection": "template-builder", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "local", "title": "Contract templates", "description": "Smart fields and versioned reusable clauses driven by Word content controls. Detect stale sections and apply updates in place.", "category": "Editor", @@ -14,11 +19,16 @@ }, { "id": "custom-ui", - "title": "Custom UI", - "description": "A full editor workspace with a custom toolbar, activity panel, comments, tracked changes, custom commands, import, and export.", + "section": "editor", + "subsection": "custom-ui", + "kind": "reference-workspace", + "status": "active", + "sourceKind": "local", + "title": "Custom UI with source-grounded citations", + "description": "Source-grounded citations grounded in the document: insert a mock RAG-generated draft, see anchored citation highlights with hover popovers, and navigate from a sources panel. Wrapped in a full editor workspace with a custom toolbar, activity panel, comments, tracked changes, custom commands, import, and export.", "category": "Editor", "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/custom-ui", + "sourcePath": "demos/editor/custom-ui", "docs": "https://docs.superdoc.dev/editor/custom-ui/overview", "thumbnail": null, "liveUrl": null, @@ -27,6 +37,11 @@ }, { "id": "grading-papers", + "section": "solutions", + "subsection": "review", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "local", "title": "Grading papers", "description": "Review student papers with documents, comments, annotations, and grading workflow UI.", "category": "Editor", @@ -40,6 +55,11 @@ }, { "id": "slack-redlining", + "section": "ai", + "subsection": "agents", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "local", "title": "Slack redlining", "description": "Use Slack and AI to insert redlines into a DOCX workflow.", "category": "AI", @@ -53,11 +73,16 @@ }, { "id": "chrome-extension", + "section": "editor", + "subsection": "integrations", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Chrome extension", "description": "Open downloaded documents in a SuperDoc-powered Chrome extension.", "category": "Integrations", "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/chrome-extension", + "sourcePath": "demos/editor/integrations/chrome-extension", "docs": null, "thumbnail": "demos/chrome-extension/demo-thumbnail.png", "liveUrl": null, @@ -66,11 +91,16 @@ }, { "id": "word-addin", + "section": "editor", + "subsection": "integrations", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Microsoft Word add-in", "description": "Synchronize documents between Microsoft Word and a SuperDoc web editor.", "category": "Integrations", "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/word-addin", + "sourcePath": "demos/editor/integrations/word-addin", "docs": null, "thumbnail": "demos/word-addin/demo-thumbnail.png", "liveUrl": null, @@ -79,6 +109,11 @@ }, { "id": "rag", + "section": "ai", + "subsection": "agents", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "external", "title": "DocRAG", "description": "Ask DOCX files questions and jump to the cited paragraph, comment, or tracked change.", "category": "AI", @@ -92,6 +127,11 @@ }, { "id": "esign", + "section": "solutions", + "subsection": "esign", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "external", "title": "eSign", "description": "Add signature fields to DOCX and PDF documents and run a signing workflow.", "category": "Solutions", @@ -105,6 +145,11 @@ }, { "id": "template-builder", + "section": "solutions", + "subsection": "template-builder", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "external", "title": "Template Builder", "description": "Build reusable DOCX templates with dynamic fields and merge data.", "category": "Solutions", @@ -118,6 +163,11 @@ }, { "id": "pdf-sign", + "section": "solutions", + "subsection": "esign", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "external", "title": "PDF signing", "description": "Run a PDF signing workflow with SuperDoc signing components.", "category": "Solutions", @@ -131,6 +181,11 @@ }, { "id": "fields-live", + "section": "solutions", + "subsection": "template-builder", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "external", "title": "Template fields", "description": "Try field placement and replacement in a deployed SuperDoc workflow.", "category": "Solutions", @@ -144,11 +199,16 @@ }, { "id": "docx-from-html", + "section": "editor", + "subsection": "superdoc", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "DOCX from HTML", "description": "Initialize a document from HTML content.", "category": "Document Engine", "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/docx-from-html", + "sourcePath": "demos/editor/superdoc/docx-from-html", "docs": "https://docs.superdoc.dev/editor/superdoc/import-export", "thumbnail": "demos/docx-from-html/demo-thumbnail.png", "liveUrl": null, @@ -156,22 +216,13 @@ "stackblitz": true, "review": "Candidate for an import/export or Document Engine example." }, - { - "id": "docxtemplater", - "title": "Docxtemplater integration", - "description": "Use SuperDoc with Docxtemplater.", - "category": "Integrations", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/docxtemplater", - "docs": null, - "thumbnail": "demos/docxtemplater/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": true, - "review": "Decide whether this belongs in monorepo demos or live integrations." - }, { "id": "fields-source", + "section": "solutions", + "subsection": "template-builder", + "kind": "workflow-demo", + "status": "hidden", + "sourceKind": "local", "title": "Fields source demo", "description": "Field annotations, drag and drop fields, and final export behavior.", "category": "Solutions", @@ -186,6 +237,11 @@ }, { "id": "linked-sections", + "section": "advanced", + "subsection": "linked-sections", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Linked sections", "description": "Document section nodes, linked child editors, and section export helpers.", "category": "Advanced", @@ -198,22 +254,13 @@ "stackblitz": true, "review": "Move to Advanced unless document sections become a primary docs surface." }, - { - "id": "text-selection", - "title": "Programmatic text selection", - "description": "Programmatic selection using low-level editor state.", - "category": "Advanced", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/text-selection", - "docs": "https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport", - "thumbnail": "demos/text-selection/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": true, - "review": "Review against the Custom UI selection and viewport APIs." - }, { "id": "html-editor", + "section": "advanced", + "subsection": "supereditor", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "HTML editor", "description": "Direct SuperEditor HTML mode.", "category": "Advanced", @@ -226,22 +273,13 @@ "stackblitz": true, "review": "Move to Advanced or archive as a legacy compatibility shim." }, - { - "id": "loading-from-json", - "title": "Load from JSON", - "description": "Load editor JSON into SuperDoc.", - "category": "Advanced", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/loading-from-json", - "docs": null, - "thumbnail": "demos/loading-from-json/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": true, - "review": "Keep only if JSON import remains a supported public path." - }, { "id": "nextjs-ssr", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Next.js SSR", "description": "Next.js SSR-safe SuperDoc loading.", "category": "Getting Started", @@ -254,50 +292,13 @@ "stackblitz": false, "review": "Compare with examples/getting-started/nextjs before keeping." }, - { - "id": "nodejs", - "title": "Node.js headless server", - "description": "Headless Node server using legacy Editor commands.", - "category": "Document Engine", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/nodejs", - "docs": "https://docs.superdoc.dev/document-engine/overview", - "thumbnail": "demos/nodejs/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": false, - "review": "Rewrite or replace with Document Engine SDK/CLI examples." - }, - { - "id": "replace-content", - "title": "Replace content", - "description": "Replace document or selection content with HTML or JSON.", - "category": "Document Engine", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/replace-content", - "docs": "https://docs.superdoc.dev/document-api/common-workflows", - "thumbnail": "demos/replace-content/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": true, - "review": "Update to Document API before moving." - }, - { - "id": "toolbar", - "title": "Toolbar customization", - "description": "Built-in toolbar custom button plus custom node authoring.", - "category": "Advanced", - "sourceRepo": "superdoc-dev/superdoc", - "sourcePath": "demos/toolbar", - "docs": "https://docs.superdoc.dev/editor/built-in-ui/toolbar", - "thumbnail": "demos/toolbar/demo-thumbnail.png", - "liveUrl": null, - "homepage": false, - "stackblitz": true, - "review": "Split toolbar configuration from custom node authoring." - }, { "id": "shim-react", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "shim", + "sourceKind": "local", "title": "React starter shim", "description": "Compatibility README for the old demo path. Use the React getting-started example.", "category": "Getting Started", @@ -308,11 +309,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/getting-started/react" }, { "id": "shim-vue", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "shim", + "sourceKind": "local", "title": "Vue starter shim", "description": "Compatibility README for the old demo path. Use the Vue getting-started example.", "category": "Getting Started", @@ -323,11 +328,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/getting-started/vue" }, { "id": "shim-vanilla", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "shim", + "sourceKind": "local", "title": "Vanilla starter shim", "description": "Compatibility README for the old demo path. Use the Vanilla JavaScript getting-started example.", "category": "Getting Started", @@ -338,11 +347,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/getting-started/vanilla" }, { "id": "shim-cdn", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "shim", + "sourceKind": "local", "title": "CDN starter shim", "description": "Compatibility README for the old demo path. Use the CDN getting-started example.", "category": "Getting Started", @@ -353,11 +366,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/getting-started/cdn" }, { "id": "shim-typescript", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "shim", + "sourceKind": "local", "title": "TypeScript starter shim", "description": "Compatibility README for the old demo path. Use the typed React getting-started example.", "category": "Getting Started", @@ -368,11 +385,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/getting-started/react" }, { "id": "shim-custom-mark", + "section": "advanced", + "subsection": "extensions", + "kind": "minimal-example", + "status": "shim", + "sourceKind": "local", "title": "Custom mark shim", "description": "Compatibility README for the old demo path. Use the advanced custom mark example.", "category": "Advanced", @@ -383,11 +404,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/advanced/extensions/custom-mark" }, { "id": "shim-custom-node", + "section": "advanced", + "subsection": "extensions", + "kind": "minimal-example", + "status": "shim", + "sourceKind": "local", "title": "Custom node shim", "description": "Compatibility README for the old demo path. Use the advanced custom node example.", "category": "Advanced", @@ -398,11 +423,15 @@ "liveUrl": null, "homepage": false, "stackblitz": false, - "status": "shim", "redirectTo": "examples/advanced/extensions/custom-node" }, { "id": "collaborative-agent", + "section": "ai", + "subsection": "agents", + "kind": "workflow-demo", + "status": "active", + "sourceKind": "local", "title": "Collaborative AI agent", "description": "Real-time collaborative DOCX editing with an AI agent. Y.js sync between a React client and a server-side OpenAI tool loop.", "category": "AI", diff --git a/demos/nodejs/README.md b/demos/nodejs/README.md index c1a73ff77f..77a97212b6 100644 --- a/demos/nodejs/README.md +++ b/demos/nodejs/README.md @@ -1,30 +1,15 @@ -# SuperDoc: Node.js Example +# Archived: headless Node.js Editor -A headless Node.js example using SuperDoc's Editor class with Express. +This demo is no longer recommended and has been removed from the demo gallery. -> Requires Node >= 20. Earlier versions are missing the `File` object. If you must use Node < 20, see the file polyfill in this example. +## Why archived -## Quick start +This demo wrapped SuperDoc's `Editor` class directly behind an Express server, which predates the supported server-side path. Headless Document API operations now run through the Node SDK and the CLI, which keep the same `editor.doc.*` surface as the browser editor. -```bash -npm install && npm run dev -``` +## Use instead -Runs an Express server at `http://localhost:3000` with a single root endpoint that returns a `.docx` file. +- [`examples/editor/collaboration/backends/node-sdk`](../../examples/editor/collaboration/backends/node-sdk) for a headless Node client that mutates documents through the Document API. +- [`examples/document-engine/ai-redlining`](../../examples/document-engine/ai-redlining) for a server-side AI-driven flow with the same engine. +- [Document Engine SDKs](https://docs.superdoc.dev/document-engine/sdks) for the full surface. -## Usage - -``` -# Returns the unchanged .docx template -http://localhost:3000 - -# Insert text -http://localhost:3000?text=hello world! - -# Insert HTML -http://localhost:3000?html=

I am a paragraph

I AM BOLD!

-``` - -## Additional docs - -See the [SuperDoc docs](https://docs.superdoc.dev/advanced/supereditor/methods) for all available editor commands and hooks. +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/replace-content/README.md b/demos/replace-content/README.md index 363c66360c..25dec73fd2 100644 --- a/demos/replace-content/README.md +++ b/demos/replace-content/README.md @@ -1,26 +1,15 @@ -# Replace Content Example +# Archived: replace content -A React example demonstrating how to replace document content with HTML or JSON using SuperDoc. +This demo is no longer recommended and has been removed from the demo gallery. -## Features +## Why archived -- Load DOCX documents -- Replace entire document or selection with custom content -- Switch between HTML and JSON input formats -- Side panel with content replacement controls +This demo replaced document content via the pre-Document-API editor commands. The supported path is the `editor.doc.*` Document API, which gives you typed inputs, dry-run previews, and the same operation IDs across browser, Node SDK, and CLI. -## Usage +## Use instead -1. Load a document using "Load Document" button -2. Open the side panel using the tab on the right -3. Choose replacement scope (Document or Selection) -4. Select content type (HTML or JSON) -5. Enter your content in the textarea -6. Click "Replace content" to apply changes +- `editor.doc.text.rewrite`, `editor.doc.insert`, `editor.doc.delete`, `editor.doc.replace` for content mutations. +- [`examples/document-api/content-controls/tagged-inline-text`](../../examples/document-api/content-controls/tagged-inline-text) and [`examples/document-api/metadata-anchors`](../../examples/document-api/metadata-anchors) for primitive-led Document API examples. +- [Document API overview](https://docs.superdoc.dev/document-api/overview). -## Running - -```bash -npm install -npm run dev -``` \ No newline at end of file +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/text-selection/README.md b/demos/text-selection/README.md index f99f7f993d..e1e29a8323 100644 --- a/demos/text-selection/README.md +++ b/demos/text-selection/README.md @@ -1,6 +1,15 @@ -# SuperDoc - Programmatic Text Selection Example +# Archived: programmatic text selection -This React-based example shows how SuperDoc can select text in a document relative to the cursor's position. +This demo is no longer recommended and has been removed from the demo gallery. -- [Based on character count](https://github.com/superdoc-dev/superdoc/blob/main/demos/text-selection/src/App.jsx) -- [Or, just grab the whole line](https://github.com/superdoc-dev/superdoc/blob/main/demos/text-selection/src/App.jsx) +## Why archived + +This demo reached into ProseMirror's `TextSelection` and `activeEditor.view` directly, which predates the supported Custom UI selection surface. The recommended path is the `ui.selection.*` and `ui.viewport.*` handles, which give you capture, restore, anchor rects, and viewport-relative geometry without reaching into editor internals. + +## Use instead + +- [`examples/editor/custom-ui/selection-capture`](../../examples/editor/custom-ui/selection-capture) for the smallest selection-capture/restore lesson. +- [Custom UI: selection and viewport](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport) for the conceptual guide. +- `ui.selection.capture`, `ui.selection.restore`, `ui.selection.getAnchorRect`, `ui.viewport.scrollIntoView` for the supported APIs. + +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/toolbar/README.md b/demos/toolbar/README.md index 8259750058..012a71ea2a 100644 --- a/demos/toolbar/README.md +++ b/demos/toolbar/README.md @@ -1,11 +1,14 @@ -# SuperDoc: Customizing the Toolbar +# Archived: customizing the toolbar -An example of how to add a custom button to the SuperDoc toolbar. This custom button inserts a random cat GIF into the document. +This demo is no longer recommended and has been removed from the demo gallery. -[We define the custom button in the `modules.toolbar.customButtons` option](https://github.com/superdoc-dev/superdoc/blob/main/demos/toolbar/src/main.js) +## Why archived -The button's action is to insert a custom `catNode`. [The custom node and its Prosemirror click-handler plugin are defined in the same file](https://github.com/superdoc-dev/superdoc/blob/main/demos/toolbar/src/main.js). +This demo bundled two unrelated lessons into one workspace: configuring a custom toolbar button, and authoring a custom ProseMirror node (a "cat GIF" node). Each lesson is now covered cleanly by its own focused example. -It is also possible to fully replace the toolbar with your own: [Headless Toolbar](https://docs.superdoc.dev/editor/built-in-ui/toolbar/overview#learn-more) +## Use instead -More customization options here: https://docs.superdoc.dev/editor/built-in-ui/toolbar/overview +- [`examples/editor/custom-ui/configurable-toolbar`](../../examples/editor/custom-ui/configurable-toolbar) for the toolbar configuration lesson. +- [`examples/advanced/extensions/custom-node`](../../examples/advanced/extensions/custom-node) for the custom-node authoring lesson. + +The source in this directory is kept for archival reference but is not maintained. diff --git a/demos/word-addin/README.md b/demos/word-addin/README.md index 0208008a31..c54abcacd9 100644 --- a/demos/word-addin/README.md +++ b/demos/word-addin/README.md @@ -1,165 +1,5 @@ -# SuperDoc MS Add-in Sync +# Moved to demos/editor/integrations/word-addin -Real-time document synchronization between Microsoft Word Add-in and web editor using WebSocket communication. +The Word add-in demo moved to [`demos/editor/integrations/word-addin`](../editor/integrations/word-addin). -## Architecture - -The system consists of three main components: -- **MS Word Add-in** (`src/taskpane/taskpane.js`) - Runs inside Microsoft Word -- **Web Editor** (`server/public/editor.js`) - Browser-based document editor -- **Node.js Server** (`server/server.js`) - WebSocket server handling real-time sync - -## WebSocket Events - -The WebSocket communication uses the following event types: - -### `client_ready` -**Sent by:** Web Editor -**Handled by:** Server (broadcasts to other clients) -**Purpose:** Signals that a browser client has loaded and is ready to receive authentication - -```javascript -// Sent by editor.js -websocket.send(JSON.stringify({ - type: 'client_ready' -})); - -// Broadcasted by server.js to other clients -{ - type: 'client_ready', - timestamp: '2024-01-01T00:00:00.000Z' -} -``` - -### `token_transfer` -**Sent by:** MS Word Add-in -**Handled by:** Server (validates and broadcasts to other clients), Web Editor -**Purpose:** Transfers authentication token from Word add-in to web editor - -```javascript -// Sent by taskpane.js -websocket.send(JSON.stringify({ - type: 'token_transfer', - token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...' -})); - -// Broadcasted by server.js after validation -{ - type: 'token_transfer', - token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...', - user: { - email: 'user@example.com', - name: 'User Name', - picture: 'https://example.com/avatar.jpg' - }, - timestamp: '2024-01-01T00:00:00.000Z' -} - -// Error response -{ - type: 'token_transfer', - error: 'Invalid token', - timestamp: '2024-01-01T00:00:00.000Z' -} -``` - -### `document_update` -**Sent by:** MS Word Add-in, Web Editor -**Handled by:** Server (validates and broadcasts to other clients), MS Word Add-in, Web Editor -**Purpose:** Real-time document synchronization between clients - -```javascript -// Sent by taskpane.js or editor.js -websocket.send(JSON.stringify({ - type: 'document_update', - token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik...', - document: 'UEsDBBQABgAIAAAAIQDb4fbL7...' // Base64 encoded .docx -})); - -// Broadcasted by server.js after validation -{ - type: 'document_update', - document: 'UEsDBBQABgAIAAAAIQDb4fbL7...', - author: 'user@example.com', - timestamp: '2024-01-01T00:00:00.000Z' -} -``` - -### `close` -**Sent by:** Server -**Handled by:** MS Word Add-in, Web Editor -**Purpose:** Notifies clients when another connection closes - -```javascript -// Broadcasted by server.js -{ - type: 'close', - timestamp: '2024-01-01T00:00:00.000Z' -} -``` - -### `error` -**Sent by:** Server -**Handled by:** MS Word Add-in, Web Editor -**Purpose:** Notifies clients when a connection error occurs - -```javascript -// Broadcasted by server.js -{ - type: 'error', - timestamp: '2024-01-01T00:00:00.000Z' -} -``` - -## Authentication Flow - -1. Web editor loads and sends `client_ready` to server -2. Server broadcasts `client_ready` to Word add-in -3. Word add-in sends `token_transfer` with user's authentication token -4. Server validates token against Auth0 and broadcasts `token_transfer` with user info to web editor -5. Both clients are now authenticated and can send `document_update` events - -## Real-time Synchronization - -- Document changes in Word trigger `document_update` events via selection change detection -- Document changes in web editor trigger `document_update` events via SuperDoc's `onEditorUpdate` callback -- All `document_update` events include the full document as Base64-encoded .docx -- Server validates authentication token before broadcasting updates -- Clients update their document content when receiving `document_update` events - -## Setup - -1. Install dependencies: - ```bash - npm install - cd server && npm install - ``` - -2. Configure environment variables: - - **Auth0 Configuration** - Set up `src/auth0-config.js`: - - Get your Auth0 domain, client ID, and audience from your [Auth0 Dashboard](https://manage.auth0.com/) - - Create a new application or use an existing Single Page Application - - Configure the redirect URLs to include your add-in's domain - - **Server Configuration** - Set up `server/.env`: - - `AUTH0_DOMAIN`: Your Auth0 domain (e.g., `yourapp.us.auth0.com`) - - `AUTH0_AUDIENCE`: Your Auth0 API identifier - - Any additional environment variables required by your cloud function - - These environment variables are required for: - - **Auth0**: Authenticating users and validating JWT tokens - - **Cloud Function**: Server-side token validation and WebSocket communication - - You can reference the example files in the same directories. - -3. Start the server: - ```bash - npm run server - ``` - -4. Build and run the add-in: - ```bash - npm run build - npm start - ``` \ No newline at end of file +It now sits under `demos/editor/integrations/` to reflect that it integrates SuperDoc into Word as a host surface, rather than being its own product workflow. diff --git a/examples/README.md b/examples/README.md index 347ef6571d..d5fef41cf0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -99,6 +99,7 @@ Put operation-level examples here even when the browser editor hosts the example | Example | Pattern | |---------|---------| | [content-controls/tagged-inline-text](./document-api/content-controls/tagged-inline-text) | The smallest content-control workflow: wrap a word, find by tag, update value. | +| [metadata-anchors](./document-api/metadata-anchors) | The smallest metadata-anchor workflow: attach a JSON payload to a span, then list, get, resolve, and remove it. | ## Document Engine diff --git a/examples/document-api/metadata-anchors/README.md b/examples/document-api/metadata-anchors/README.md new file mode 100644 index 0000000000..fb29dc00ba --- /dev/null +++ b/examples/document-api/metadata-anchors/README.md @@ -0,0 +1,40 @@ +# Metadata anchors + +The smallest anchored-payload workflow: attach a JSON payload to a span of text, then list, get, resolve, and remove it. + +## What this teaches + +- **Setup** (not the lesson, but needed so the lesson has something to act on): + - `doc.clearContent({})` clears the seeded document. + - `doc.insert({ value })` seeds one paragraph that contains the anchor phrase. + - `doc.extract({})` is used to find the anchor block id so the example can build a stable `SelectionTarget` for the click handlers. + +- **Teaching surface** (the actual lesson, in click order): + - `doc.metadata.attach({ target, namespace, id, payload })` anchors the payload to the span. + - `doc.metadata.list({ namespace })` lists every entry in the namespace. + - `doc.metadata.get({ id })` returns one entry's payload. + - `doc.metadata.resolve({ id })` returns the `SelectionTarget` the anchor currently covers. + - `doc.metadata.remove({ id })` strips the anchor wrapper and the payload entry. + +Every operation goes through `editor.doc.*`. The same operation set runs headless via the Node SDK and CLI. + +## Why this primitive exists + +A metadata anchor is a hidden inline content control whose `w:tag` carries a stable id, paired with a JSON payload in a namespaced custom XML data part. The customer-facing use case people most often build on this is **source-grounded citations** (see [`demos/custom-ui`](../../../demos/custom-ui) for the composed workflow), but the primitive is generic: any span-bound payload (citations, suggestion provenance, review markers, structured annotations) uses the same five operations. + +For the conceptual guide and the storage model, see [Document API > Anchored metadata](https://docs.superdoc.dev/document-api/features/anchored-metadata). + +## Run + +```bash +pnpm install +pnpm dev +``` + +Click **Attach**, then **List** / **Get** / **Resolve** to inspect the entry, then **Remove** to strip it. Each click prints the operation's return value in the **Last operation** panel. + +## See also + +- [`demos/custom-ui`](../../../demos/custom-ui): composed reference workspace that uses these primitives behind a source-grounded citation flow with highlights, hover popovers, and a sources panel. +- [Document API > Anchored metadata](https://docs.superdoc.dev/document-api/features/anchored-metadata): feature guide. +- [Document API reference: metadata.*](https://docs.superdoc.dev/document-api/reference/metadata): per-operation inputs, outputs, and failure codes. diff --git a/examples/document-api/metadata-anchors/index.html b/examples/document-api/metadata-anchors/index.html new file mode 100644 index 0000000000..12f61c254e --- /dev/null +++ b/examples/document-api/metadata-anchors/index.html @@ -0,0 +1,38 @@ + + + + + + SuperDoc: metadata anchors + + +
+
+
+
+ +
+ + + diff --git a/examples/document-api/metadata-anchors/package.json b/examples/document-api/metadata-anchors/package.json new file mode 100644 index 0000000000..8f3b10130b --- /dev/null +++ b/examples/document-api/metadata-anchors/package.json @@ -0,0 +1,18 @@ +{ + "name": "@superdoc-examples/metadata-anchors", + "private": true, + "type": "module", + "scripts": { + "predev": "pnpm --filter superdoc build", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "superdoc": "workspace:*" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/document-api/metadata-anchors/src/main.ts b/examples/document-api/metadata-anchors/src/main.ts new file mode 100644 index 0000000000..315c0c2ea0 --- /dev/null +++ b/examples/document-api/metadata-anchors/src/main.ts @@ -0,0 +1,209 @@ +/** + * Metadata anchors: the smallest anchored-payload workflow. + * + * Setup (not the lesson): + * 1. Seed one paragraph with anchor text. + * + * Teaching surface (the lesson, in click order): + * 1. `editor.doc.metadata.attach({ target, namespace, id, payload })` + * 2. `editor.doc.metadata.list({ namespace })` + * 3. `editor.doc.metadata.get({ id })` + * 4. `editor.doc.metadata.resolve({ id })` + * 5. `editor.doc.metadata.remove({ id })` + * + * Every operation goes through `editor.doc.*`. The same operation set + * runs headless via the Node SDK and CLI. + * + * A metadata anchor is a hidden inline content control around the + * anchored text whose `w:tag` carries a stable id, paired with a JSON + * payload in a namespaced custom XML data part. The customer-facing + * use case people most often build on this is source-grounded + * citations (see `demos/custom-ui`); the primitive is general and + * works for any span-bound payload. + */ + +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; +import './style.css'; + +type SelectionTarget = { + kind: 'selection'; + start: { kind: 'text'; blockId: string; offset: number }; + end: { kind: 'text'; blockId: string; offset: number }; +}; + +type AnchoredMetadataPayload = Record; + +type AnchoredMetadataInfo = { + id: string; + namespace: string; + partName: string; + payload: AnchoredMetadataPayload; +}; + +type AnchoredMetadataResolveInfo = { + id: string; + target: SelectionTarget; +}; + +type AnchoredMetadataAttachResult = + | { success: true; id: string; namespace: string; partName: string } + | { success: false; failure: { code: string; message: string } }; + +type AnchoredMetadataMutationResult = + | { success: true } + | { success: false; failure: { code: string; message: string } }; + +type DocumentApi = { + clearContent(input: Record): { success: boolean; failure?: { code: string; message: string } }; + insert(input: { value: string }): { success: boolean; failure?: { code: string; message: string } }; + extract(input: Record): { blocks: Array<{ nodeId: string; type: string; text: string }> }; + metadata: { + attach(input: { + target: SelectionTarget; + namespace: string; + id?: string; + payload: AnchoredMetadataPayload; + }): AnchoredMetadataAttachResult; + list(input: { namespace?: string }): { total: number; items: Array<{ id: string; namespace: string; partName: string }> }; + get(input: { id: string }): AnchoredMetadataInfo | null; + resolve(input: { id: string }): AnchoredMetadataResolveInfo | null; + remove(input: { id: string }): AnchoredMetadataMutationResult; + }; +}; + +const NAMESPACE = 'urn:superdoc:example:metadata-anchors:1'; +const ID = 'anchor-1'; +const ANCHOR_PHRASE = 'metadata anchors'; +const SEED = `Hover over ${ANCHOR_PHRASE} below to see this primitive in action. Open the console to follow the operation receipts.`; +const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }; + +const statusEl = qs('#status'); +const resultEl = qs('#result'); +const attachBtn = qs('#attach'); +const listBtn = qs('#list'); +const getBtn = qs('#get'); +const resolveBtn = qs('#resolve'); +const removeBtn = qs('#remove'); + +let api: DocumentApi | null = null; +let anchorTarget: SelectionTarget | null = null; +let attached = false; +setBusy(true); + +const superdoc = new SuperDoc({ + selector: '#editor', + documentMode: 'editing', + jsonOverride: EMPTY_DOC, + modules: { comments: false }, + telemetry: { enabled: false }, + onReady: ({ superdoc: sd }) => void initialize(sd as SuperDoc & { activeEditor: { doc: DocumentApi } | null }), +}); + +attachBtn.addEventListener('click', () => void run(doAttach)); +listBtn.addEventListener('click', () => void run(doList)); +getBtn.addEventListener('click', () => void run(doGet)); +resolveBtn.addEventListener('click', () => void run(doResolve)); +removeBtn.addEventListener('click', () => void run(doRemove)); + +async function initialize(sd: SuperDoc & { activeEditor: { doc: DocumentApi } | null }): Promise { + if (!sd.activeEditor?.doc) return setStatus('Document API unavailable'); + api = sd.activeEditor.doc; + + const cleared = api.clearContent({}); + if (!cleared.success && cleared.failure?.code !== 'NO_OP') return setStatus(cleared.failure?.message ?? 'Setup failed'); + + const inserted = api.insert({ value: SEED }); + if (!inserted.success) return setStatus(inserted.failure?.message ?? 'Setup failed'); + + // Cache the SelectionTarget for `ANCHOR_PHRASE` so each Attach click + // re-runs the same operation against the same span. + const block = api.extract({}).blocks.find((b) => b.text.includes(ANCHOR_PHRASE)); + if (!block) return setStatus('Setup failed: anchor span not found'); + const start = block.text.indexOf(ANCHOR_PHRASE); + anchorTarget = { + kind: 'selection', + start: { kind: 'text', blockId: block.nodeId, offset: start }, + end: { kind: 'text', blockId: block.nodeId, offset: start + ANCHOR_PHRASE.length }, + }; + + setStatus('Ready. Click Attach to anchor a payload to the highlighted span.'); + refreshButtons(); +} + +// The lesson. + +function doAttach(): unknown { + if (!api || !anchorTarget) return null; + const result = api.metadata.attach({ + target: anchorTarget, + namespace: NAMESPACE, + id: ID, + payload: { note: 'minimal example payload', createdAt: new Date().toISOString() }, + }); + if (result.success) attached = true; + return result; +} + +function doList(): unknown { + if (!api) return null; + return api.metadata.list({ namespace: NAMESPACE }); +} + +function doGet(): unknown { + if (!api) return null; + return api.metadata.get({ id: ID }); +} + +function doResolve(): unknown { + if (!api) return null; + return api.metadata.resolve({ id: ID }); +} + +function doRemove(): unknown { + if (!api) return null; + const result = api.metadata.remove({ id: ID }); + if (result.success) attached = false; + return result; +} + +function run(op: () => unknown): void { + if (!api) return; + setBusy(true); + try { + const out = op(); + resultEl.textContent = JSON.stringify(out, null, 2); + setStatus('Done.'); + } catch (err) { + resultEl.textContent = String(err); + setStatus('Operation threw.'); + } finally { + refreshButtons(); + } +} + +function refreshButtons(): void { + attachBtn.disabled = !api || attached; + listBtn.disabled = !api; + getBtn.disabled = !api || !attached; + resolveBtn.disabled = !api || !attached; + removeBtn.disabled = !api || !attached; +} + +function setBusy(busy: boolean): void { + document.querySelectorAll('button').forEach((b) => (b.disabled = busy)); +} + +function setStatus(text: string): void { + statusEl.textContent = text; +} + +function qs(selector: string): T { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element ${selector}`); + return el; +} + +const teardown = () => superdoc.destroy(); +window.addEventListener('beforeunload', teardown); +if (import.meta.hot) import.meta.hot.dispose(teardown); diff --git a/examples/document-api/metadata-anchors/src/style.css b/examples/document-api/metadata-anchors/src/style.css new file mode 100644 index 0000000000..431d3e3ed7 --- /dev/null +++ b/examples/document-api/metadata-anchors/src/style.css @@ -0,0 +1,100 @@ +:root { + --example-bg: var(--sd-ui-bg, #fff); + --example-canvas: var(--sd-surface-canvas, var(--sd-color-gray-50, #fafafa)); + --example-border: var(--sd-ui-border, var(--sd-color-gray-400, #dbdbdb)); + --example-text: var(--sd-color-gray-900, #212121); + --example-text-muted: var(--sd-ui-text-muted, var(--sd-color-gray-700, #666)); + --example-accent: var(--sd-ui-action, var(--sd-color-blue-500, #1355ff)); + --example-accent-hover: var(--sd-ui-action-hover, var(--sd-color-blue-600, #0f44cc)); + --example-danger: var(--sd-ui-danger, #c0392b); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--sd-ui-font-family, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif); + font-size: var(--sd-font-size-400, 14px); + color: var(--example-text); + background: var(--example-canvas); +} + +code, pre { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; + font-size: 0.9em; +} + +button { font: inherit; cursor: pointer; } + +.app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; } +.editor-area { overflow: auto; padding: 12px; } +.sidebar { + background: var(--example-bg); + border-left: 1px solid var(--example-border); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.panel { padding: 14px 16px; border-bottom: 1px solid var(--example-border); } +.panel h2, .panel h3 { + font-size: var(--sd-font-size-300, 13px); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--example-text-muted); + margin: 0 0 8px; +} +.panel .hint { + font-size: var(--sd-font-size-200, 12px); + color: var(--example-text-muted); + margin: 0 0 12px; + line-height: 1.5; +} + +.actions { display: flex; flex-direction: column; gap: 6px; } + +.btn { + height: 30px; + padding: 0 14px; + background: transparent; + border: 1px solid var(--example-border); + border-radius: var(--sd-radius-50, 4px); + color: var(--example-text); + width: 100%; +} +.btn.primary { + background: var(--example-accent); + border-color: var(--example-accent); + color: var(--sd-ui-action-text, #fff); +} +.btn.primary:hover:not(:disabled) { + background: var(--example-accent-hover); + border-color: var(--example-accent-hover); + color: var(--sd-ui-action-text, #fff); +} +.btn.danger:not(:disabled) { color: var(--example-danger); border-color: var(--example-danger); } +.btn:disabled { color: var(--example-text-muted); cursor: not-allowed; opacity: 0.5; } + +.result { + margin: 0; + padding: 10px 12px; + background: var(--example-canvas); + border: 1px solid var(--example-border); + border-radius: var(--sd-radius-50, 4px); + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: var(--sd-font-size-200, 12px); + line-height: 1.4; +} + +.status { + margin: 0; + padding: 12px 16px; + font-size: var(--sd-font-size-200, 12px); + color: var(--example-text-muted); + border-top: 1px solid var(--example-border); + margin-top: auto; +} diff --git a/examples/document-api/metadata-anchors/tsconfig.json b/examples/document-api/metadata-anchors/tsconfig.json new file mode 100644 index 0000000000..5434d9a9f2 --- /dev/null +++ b/examples/document-api/metadata-anchors/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/document-api/metadata-anchors/vite.config.ts b/examples/document-api/metadata-anchors/vite.config.ts new file mode 100644 index 0000000000..9f134aecf2 --- /dev/null +++ b/examples/document-api/metadata-anchors/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: '127.0.0.1', + }, +}); diff --git a/examples/getting-started/README.md b/examples/getting-started/README.md index c2a0ba0ba2..f80c9e25cb 100644 --- a/examples/getting-started/README.md +++ b/examples/getting-started/README.md @@ -7,6 +7,7 @@ Minimal examples for integrating SuperDoc into your project. Each example loads | [react](./react) | React + TypeScript with Vite | [Guide](https://docs.superdoc.dev/getting-started/frameworks/react) | | [vue](./vue) | Vue 3 + TypeScript with Vite | [Guide](https://docs.superdoc.dev/getting-started/frameworks/vue) | | [nuxt](./nuxt) | Nuxt 4 + TypeScript with Vite | [Guide](https://docs.superdoc.dev/getting-started/frameworks/nuxt) | +| [solid](./solid) | SolidJS + TypeScript with Vite | [Guide](https://docs.superdoc.dev/getting-started/frameworks/solid) | | [vanilla](./vanilla) | Plain JavaScript with Vite | [Guide](https://docs.superdoc.dev/getting-started/quickstart) | | [cdn](./cdn) | Zero build tools โ€” just an HTML file | [Guide](https://docs.superdoc.dev/getting-started/quickstart) | diff --git a/examples/getting-started/solid/.gitignore b/examples/getting-started/solid/.gitignore new file mode 100644 index 0000000000..751513ce1b --- /dev/null +++ b/examples/getting-started/solid/.gitignore @@ -0,0 +1,28 @@ +dist +.wrangler +.output +.vercel +.netlify +.vinxi +app.config.timestamp_*.js + +# Environment +.env +.env*.local + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +*.launch +.settings/ + +# Temp +gitignore + +# System Files +.DS_Store +Thumbs.db diff --git a/examples/getting-started/solid/README.md b/examples/getting-started/solid/README.md new file mode 100644 index 0000000000..38b4997618 --- /dev/null +++ b/examples/getting-started/solid/README.md @@ -0,0 +1,51 @@ +# SuperDoc Solid + TypeScript Example + +A TypeScript example demonstrating `superdoc` integration with Solid. + +## Features Demonstrated + +- **File Upload** - Load `.docx` files with typed event handlers +- **Mode Switching** - Toggle between editing, suggesting, and viewing modes +- **Instance API** - Access SuperDoc instance methods with proper typing +- **Export** - Download documents as DOCX +- **User Info** - Pass typed user information to the editor +- **Loading States** - Show loading UI while SuperDoc initializes +- **Event Callbacks** - Typed callbacks for editor events + +## Run + +```bash +# From repo root +pnpm install +pnpm -C examples/getting-started/solid dev +``` + +## Key Types Used + +```typescript +import { SuperDoc } from 'superdoc'; + +type SuperDocInstance = InstanceType; +type SuperDocConfig = ConstructorParameters[0]; +type DocumentMode = NonNullable; + +// Ref for accessing instance +let superdoc: SuperDocInstance | null = null; + +// Typed document mode signal +const [mode, setMode] = createSignal('editing'); + +// Access instance +superdoc?.setDocumentMode(mode()); +await superdoc?.export({ triggerDownload: true }); +``` + +## Project Structure + +```text +src/ +โ”œโ”€โ”€ App.tsx # Main component with SuperDoc integration +โ”œโ”€โ”€ App.css # Styles +โ”œโ”€โ”€ index.tsx # Entry point +โ””โ”€โ”€ index.css # Global styles +``` diff --git a/examples/getting-started/solid/index.html b/examples/getting-started/solid/index.html new file mode 100644 index 0000000000..5b084814e6 --- /dev/null +++ b/examples/getting-started/solid/index.html @@ -0,0 +1,14 @@ + + + + + + + SuperDoc Solid + TypeScript Example + + + +
+ + + diff --git a/examples/getting-started/solid/package.json b/examples/getting-started/solid/package.json new file mode 100644 index 0000000000..d31585f602 --- /dev/null +++ b/examples/getting-started/solid/package.json @@ -0,0 +1,17 @@ +{ + "name": "superdoc-solid-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite" + }, + "dependencies": { + "solid-js": "^1.9.5", + "superdoc": "latest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vite": "^6.2.0", + "vite-plugin-solid": "^2.11.12" + } +} diff --git a/examples/getting-started/solid/src/App.css b/examples/getting-started/solid/src/App.css new file mode 100644 index 0000000000..c9fb3dbd23 --- /dev/null +++ b/examples/getting-started/solid/src/App.css @@ -0,0 +1,218 @@ +/* Layout */ +.app { + height: 100vh; + display: flex; + flex-direction: column; + background: #f5f5f5; +} + +/* Header */ +.header { + padding: 1rem 1.5rem; + background: #1a1a2e; + color: white; + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} + +.header h1 { + font-size: 1.25rem; + font-weight: 600; + margin-right: auto; +} + +/* Controls */ +.controls { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: transparent; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.btn.primary { + background: #3b82f6; + border-color: #3b82f6; +} + +.btn.primary:hover { + background: #2563eb; + border-color: #2563eb; +} + +.btn.large { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mode Switcher */ +.mode-switcher { + display: flex; + background: rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 2px; +} + +.mode-btn { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + font-weight: 500; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + transition: all 0.15s; +} + +.mode-btn:hover:not(:disabled) { + color: white; +} + +.mode-btn.active { + background: white; + color: #1a1a2e; +} + +.mode-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Actions */ +.actions { + display: flex; + gap: 0.5rem; +} + +/* Status */ +.status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #fbbf24; +} + +.status-dot.ready { + background: #22c55e; +} + +.status-dot.loading { + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Editor Area */ +.editor-area { + flex: 1; + min-height: 0; + background: white; +} + +/* Empty State */ +.empty-state { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #fafafa; +} + +.empty-content { + text-align: center; + color: #666; +} + +.empty-content h2 { + font-size: 1.5rem; + font-weight: 600; + color: #333; + margin-bottom: 0.5rem; +} + +.empty-content p { + margin-bottom: 1.5rem; +} + +/* Loading State */ +.loading-state { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + color: #666; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .header { + padding: 1rem; + } + + .header h1 { + width: 100%; + margin-bottom: 0.5rem; + } + + .controls { + width: 100%; + justify-content: flex-start; + } +} diff --git a/examples/getting-started/solid/src/App.tsx b/examples/getting-started/solid/src/App.tsx new file mode 100644 index 0000000000..0fa5ef832e --- /dev/null +++ b/examples/getting-started/solid/src/App.tsx @@ -0,0 +1,173 @@ +import { createEffect, createSignal, createUniqueId, onCleanup, Show, untrack } from 'solid-js'; +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; +import './App.css'; + +type SuperDocInstance = InstanceType; +type SuperDocConfig = ConstructorParameters[0]; +type DocumentMode = NonNullable; + +/** + * SuperDoc Solid + TypeScript Example + * + * Demonstrates: + * - File upload with type safety + * - Document mode switching (editing/viewing/suggesting) + * - Export functionality via ref API + * - User information + * - Loading states + * - Event callbacks + */ +export default function App() { + // Document state + const [document, setDocument] = createSignal(null); + const [mode, setMode] = createSignal('editing'); + const [isReady, setIsReady] = createSignal(false); + const [editorContainerRef, setEditorContainerRef] = createSignal(null); + + // Ref for accessing SuperDoc instance methods + let fileInputRef: HTMLInputElement | undefined; + let superdoc: SuperDocInstance | null = null; + + const containerId = `superdoc${createUniqueId()}`; + const toolbarId = `superdoc-toolbar${createUniqueId()}`; + + // Current user (typed) + const currentUser = { + name: 'John Doe', + email: 'john@example.com', + }; + + function handleGetHTML() { + const html = superdoc?.getHTML(); + if (!html) return; + + console.log('Document HTML:', html); + alert(`Document has ${html.length} section(s). Check console for HTML.`); + } + + function selectMode(nextMode: DocumentMode) { + setMode(nextMode); + superdoc?.setDocumentMode(nextMode); + } + + function ModeButton(props: { targetMode: DocumentMode; label: string }) { + return ( + + ); + } + + createEffect(() => { + const doc = document(); + if (!doc || !editorContainerRef()) return; + + superdoc = new SuperDoc({ + selector: `#${CSS.escape(containerId)}`, + toolbar: `#${CSS.escape(toolbarId)}`, + document: doc, + documentMode: untrack(() => mode()), + role: 'editor', + user: currentUser, + rulers: true, + onReady: (editor) => { + console.log('SuperDoc ready:', editor.superdoc); + setIsReady(true); + }, + onEditorCreate: (event) => { + console.log('ProseMirror editor created:', event); + }, + onEditorUpdate: () => { + console.log('Document updated'); + }, + onContentError: (event) => { + console.error('Content error:', event); + }, + }); + + onCleanup(() => { + superdoc?.destroy(); + superdoc = null; + }); + }); + + return ( +
+
+

SuperDoc Solid + TypeScript

+
+ + { + const file = e.target.files?.[0]; + if (file && file.name.endsWith('.docx')) { + setDocument(file); + setIsReady(false); + } + }} + /> + +
+ + + +
+
+ +
+ + +
+
+
+ +
+ + {isReady() ? `Ready - ${mode()} mode` : 'Loading...'} +
+
+
+
+ +
+

No Document Loaded

+

Click "Open Document" to load a .docx file

+ +
+
+ } + > +
+
+ +
+
+

Loading document...

+
+ + + +
+ ); +} diff --git a/examples/getting-started/solid/src/index.css b/examples/getting-started/solid/src/index.css new file mode 100644 index 0000000000..558e2a6f6f --- /dev/null +++ b/examples/getting-started/solid/src/index.css @@ -0,0 +1,20 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + font-family: + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} diff --git a/examples/getting-started/solid/src/index.tsx b/examples/getting-started/solid/src/index.tsx new file mode 100644 index 0000000000..fad27d4cb0 --- /dev/null +++ b/examples/getting-started/solid/src/index.tsx @@ -0,0 +1,14 @@ +/* @refresh reload */ +import { render } from 'solid-js/web'; +import './index.css'; +import App from './App'; + +const root = document.getElementById('root'); + +if (import.meta.env.DEV && !(root instanceof HTMLElement)) { + throw new Error( + 'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?', + ); +} + +render(() => , root!); diff --git a/examples/getting-started/solid/tsconfig.json b/examples/getting-started/solid/tsconfig.json new file mode 100644 index 0000000000..8b4ebee2ba --- /dev/null +++ b/examples/getting-started/solid/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + // General + "jsx": "preserve", + "jsxImportSource": "solid-js", + "target": "ESNext", + + // Modules + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "isolatedModules": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + + // Type Checking & Safety + "strict": true, + "types": ["vite/client"] + } +} diff --git a/examples/getting-started/solid/vite.config.ts b/examples/getting-started/solid/vite.config.ts new file mode 100644 index 0000000000..ea2923ba7b --- /dev/null +++ b/examples/getting-started/solid/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], +}); diff --git a/examples/manifest.json b/examples/manifest.json index fccc4618e5..1cb6385292 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -1,6 +1,11 @@ [ { "id": "getting-started-react", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "React starter", "category": "Getting Started", "surface": "Frameworks", @@ -11,6 +16,11 @@ }, { "id": "getting-started-vue", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Vue starter", "category": "Getting Started", "surface": "Frameworks", @@ -21,6 +31,11 @@ }, { "id": "getting-started-vanilla", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Vanilla JavaScript starter", "category": "Getting Started", "surface": "Frameworks", @@ -31,6 +46,11 @@ }, { "id": "getting-started-cdn", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "CDN starter", "category": "Getting Started", "surface": "Frameworks", @@ -41,6 +61,11 @@ }, { "id": "getting-started-angular", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Angular starter", "category": "Getting Started", "surface": "Frameworks", @@ -51,6 +76,11 @@ }, { "id": "getting-started-nextjs", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Next.js starter", "category": "Getting Started", "surface": "Frameworks", @@ -61,6 +91,11 @@ }, { "id": "getting-started-nuxt", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Nuxt starter", "category": "Getting Started", "surface": "Frameworks", @@ -71,6 +106,11 @@ }, { "id": "getting-started-laravel", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Laravel starter", "category": "Getting Started", "surface": "Frameworks", @@ -79,8 +119,28 @@ "docs": "https://docs.superdoc.dev/getting-started/frameworks/laravel", "ci": true }, + { + "id": "getting-started-solid", + "section": "getting-started", + "subsection": "frameworks", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", + "title": "Solid starter", + "category": "Getting Started", + "surface": "Frameworks", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "examples/getting-started/solid", + "docs": "https://docs.superdoc.dev/getting-started/frameworks/solid", + "ci": true + }, { "id": "editor-built-in-comments", + "section": "editor", + "subsection": "built-in-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Built-in comments", "category": "Editor", "surface": "Built-in UI", @@ -91,6 +151,11 @@ }, { "id": "editor-built-in-track-changes", + "section": "editor", + "subsection": "built-in-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Built-in track changes", "category": "Editor", "surface": "Built-in UI", @@ -101,6 +166,11 @@ }, { "id": "editor-built-in-toolbar", + "section": "editor", + "subsection": "built-in-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Built-in toolbar", "category": "Editor", "surface": "Built-in UI", @@ -111,6 +181,11 @@ }, { "id": "editor-custom-ui-selection-capture", + "section": "editor", + "subsection": "custom-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Custom UI: selection capture", "category": "Editor", "surface": "Custom UI", @@ -121,6 +196,11 @@ }, { "id": "editor-custom-ui-configurable-toolbar", + "section": "editor", + "subsection": "custom-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Custom UI: configurable toolbar", "category": "Editor", "surface": "Custom UI", @@ -131,6 +211,11 @@ }, { "id": "document-api-content-controls-tagged-inline-text", + "section": "document-engine", + "subsection": "document-api", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Content controls: tagged inline text", "category": "Document API", "surface": "Content controls", @@ -139,8 +224,28 @@ "docs": "https://docs.superdoc.dev/document-api/features/content-controls", "ci": false }, + { + "id": "document-api-metadata-anchors", + "section": "document-engine", + "subsection": "document-api", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", + "title": "Metadata anchors", + "category": "Document API", + "surface": "Metadata anchors", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "examples/document-api/metadata-anchors", + "docs": "https://docs.superdoc.dev/document-api/features/anchored-metadata", + "ci": false + }, { "id": "editor-theming", + "section": "editor", + "subsection": "theming", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Theming", "category": "Editor", "surface": "Theming", @@ -151,6 +256,11 @@ }, { "id": "editor-spell-check-typo-js", + "section": "editor", + "subsection": "spell-check", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Spell check with Typo.js", "category": "Editor", "surface": "Spell check", @@ -161,6 +271,11 @@ }, { "id": "editor-spell-check-languagetool", + "section": "editor", + "subsection": "spell-check", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Spell check with LanguageTool", "category": "Editor", "surface": "Spell check", @@ -171,6 +286,11 @@ }, { "id": "editor-collaboration-superdoc-yjs", + "section": "editor", + "subsection": "collaboration", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "SuperDoc Yjs collaboration", "category": "Editor", "surface": "Collaboration", @@ -181,6 +301,11 @@ }, { "id": "editor-collaboration-hocuspocus", + "section": "editor", + "subsection": "collaboration", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Hocuspocus collaboration", "category": "Editor", "surface": "Collaboration", @@ -191,6 +316,11 @@ }, { "id": "editor-collaboration-liveblocks", + "section": "editor", + "subsection": "collaboration", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Liveblocks collaboration", "category": "Editor", "surface": "Collaboration", @@ -201,6 +331,11 @@ }, { "id": "editor-collaboration-node-sdk-backend", + "section": "editor", + "subsection": "collaboration", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "Node SDK collaboration backend", "category": "Editor", "surface": "Collaboration", @@ -211,6 +346,11 @@ }, { "id": "editor-collaboration-fastapi-backend", + "section": "editor", + "subsection": "collaboration", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "FastAPI collaboration backend", "category": "Editor", "surface": "Collaboration", @@ -221,6 +361,11 @@ }, { "id": "document-engine-diffing", + "section": "document-engine", + "subsection": "diffing", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Diffing", "category": "Document Engine", "surface": "Diffing", @@ -231,6 +376,11 @@ }, { "id": "ai-bedrock", + "section": "ai", + "subsection": "agents", + "kind": "integration-example", + "status": "active", + "sourceKind": "local", "title": "AWS Bedrock", "category": "AI", "surface": "Agents", @@ -241,6 +391,11 @@ }, { "id": "ai-streaming", + "section": "ai", + "subsection": "agents", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Streaming into an editor", "category": "AI", "surface": "Agents", @@ -251,6 +406,11 @@ }, { "id": "ai-redlining", + "section": "ai", + "subsection": "agents", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "AI redlining", "category": "AI", "surface": "Agents", @@ -261,6 +421,11 @@ }, { "id": "document-engine-ai-redlining", + "section": "document-engine", + "subsection": "sdks", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "AI redlining (server-side)", "category": "Document Engine", "surface": "AI", @@ -271,6 +436,11 @@ }, { "id": "advanced-headless-toolbar", + "section": "editor", + "subsection": "custom-ui", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Headless Toolbar", "category": "Advanced", "surface": "Headless Toolbar", @@ -281,6 +451,11 @@ }, { "id": "advanced-extension-custom-mark", + "section": "advanced", + "subsection": "extensions", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Custom mark", "category": "Advanced", "surface": "Extensions", @@ -291,6 +466,11 @@ }, { "id": "advanced-extension-custom-node", + "section": "advanced", + "subsection": "extensions", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", "title": "Custom node", "category": "Advanced", "surface": "Extensions", diff --git a/package.json b/package.json index 8adb0b9ad6..a806d24079 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:superdoc": "vitest run --root ./packages/superdoc", "pretest:doc-api-stories": "pnpm run generate:all", "test:doc-api-stories": "pnpm --silent --filter @superdoc-testing/doc-api-stories test", + "test:document-api-smoke": "pnpm --silent --filter @superdoc-testing/document-api-smoke test", "test:cov": "node scripts/test-cov.mjs", "pretest:behavior": "node scripts/free-port.mjs 9990", "test:behavior": "pnpm --filter @superdoc-testing/behavior test", @@ -70,6 +71,8 @@ "watch": "pnpm --prefix packages/superdoc run watch:es", "check:all": "pnpm run format && pnpm run lint:fix && pnpm --prefix packages/super-editor run types:build && pnpm run test", "check:examples-demos": "bun scripts/validate-examples-demos.ts", + "check:public-contract": "node scripts/check-public-contract.mjs", + "report:public-contract": "node scripts/report-public-contract.mjs", "local:publish": "pnpm --prefix packages/superdoc version prerelease --preid=local && pnpm --prefix packages/superdoc publish --registry http://localhost:4873", "update-preset-geometry": "ROOT=$(pwd) && cd ../superdoc-devtools/preset-geometry && pnpm run build && cp ./dist/index.js ./dist/index.js.map ./dist/index.d.ts \"$ROOT/packages/preset-geometry/\"", "manual-tag": "bash scripts/manual-tag.sh", @@ -77,7 +80,7 @@ "generate:all": "node scripts/generate-all.mjs", "docapi:all": "pnpm run generate:all && pnpm exec tsc -b packages/document-api && pnpm --prefix apps/cli run build && pnpm --prefix packages/sdk run build:node", "docapi:sync": "pnpm exec tsx packages/document-api/scripts/generate-contract-outputs.ts", - "docapi:check": "pnpm exec tsx packages/document-api/scripts/check-contract-parity.ts && pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts", + "docapi:check": "pnpm exec tsx packages/document-api/scripts/check-contract-parity.ts && pnpm exec tsx packages/document-api/scripts/check-contract-outputs.ts && pnpm exec tsx packages/document-api/scripts/check-examples.ts && pnpm exec tsx packages/document-api/scripts/check-overview-alignment.ts", "docapi:sync:check": "pnpm run docapi:sync && pnpm run docapi:check", "test:cli": "pnpm --prefix apps/cli run test", "cli:prepare": "pnpm run test:cli && pnpm --prefix apps/cli run build:prepublish", @@ -178,8 +181,5 @@ "readable-stream@3.6.2": "patches/readable-stream@3.6.2.patch", "openapi-types@12.1.3": "patches/openapi-types@12.1.3.patch" } - }, - "dependencies": { - "superdoc": "link:../../../packages/superdoc" } } diff --git a/packages/document-api/scripts/README.md b/packages/document-api/scripts/README.md index af40636f16..6832b3d4fe 100644 --- a/packages/document-api/scripts/README.md +++ b/packages/document-api/scripts/README.md @@ -8,11 +8,20 @@ This folder contains deterministic generator/check entry points for the Document - `check-*` scripts validate generated artifacts or docs and fail with non-zero exit code on drift. - Root `package.json` exposes three canonical entry points: - `pnpm run docapi:sync` โ€” runs `generate-contract-outputs.ts` - - `pnpm run docapi:check` โ€” runs `check-contract-parity.ts` + `check-contract-outputs.ts` + - `pnpm run docapi:check` โ€” runs `check-contract-parity.ts` + `check-contract-outputs.ts` + `check-examples.ts` - `pnpm run docapi:sync:check` โ€” sync then check - Pre-commit hook (`lefthook.yml`) auto-runs `docapi:sync` when contract or script sources are staged, and restages `reference/` and `overview.mdx`. - CI workflow (`ci-document-api.yml`) generates outputs, checks overview freshness, then runs `docapi:check` on PRs touching document-api paths. +### Which checks run where (SD-673 Phase 2) + +Two buckets: + +| Bucket | Scripts | Notes | +| --- | --- | --- | +| **Per-PR (wired into `docapi:check`)** | `check-contract-parity`, `check-contract-outputs`, `check-examples`, `check-overview-alignment` | Run on every doc-api PR via `ci-document-api.yml`. | +| **Focused / manual** | `check-stable-schemas`, `check-agent-artifacts`, `check-generated-reference-docs` | Targeted local-debug variants of `check-contract-outputs` (the per-PR superset). Useful when iterating on one artifact area without re-running the full superset. Not wired into CI by design. | + ## Manual vs generated boundaries - Hand-authored inputs: @@ -40,8 +49,7 @@ Do not hand-edit generated output files. Regenerate instead. | `generate-agent-artifacts.ts` | generate | Regenerate agent artifacts (remediation/workflow/compatibility) | Contract snapshot | `packages/document-api/generated/agent/*` | Focused agent-artifact regeneration | | `check-generated-reference-docs.ts` | check | Validate generated reference docs and overview generated block drift | Contract snapshot + `apps/docs/document-api/reference` + overview | None | Focused docs generation check | | `generate-reference-docs.ts` | generate | Regenerate generated reference docs and overview generated block | Contract snapshot + overview markers | `apps/docs/document-api/reference/*`, generated block in `apps/docs/document-api/overview.mdx` | Focused docs regeneration | -| `check-overview-alignment.ts` | check | Enforce overview quality rules (required copy/markers, forbidden placeholders, known API paths only) | `apps/docs/document-api/overview.mdx` + `DOCUMENT_API_MEMBER_PATHS` | None | Docs consistency gate | -| `check-doc-coverage.ts` | check | Ensure every operation has a `### \`\`` section in `src/README.md` | `packages/document-api/src/README.md` + `OPERATION_IDS` | None | Contract/docs coverage gate | +| `check-overview-alignment.ts` | check | Enforce structural correctness of the overview page: reference link, generated section markers, no stale placeholders, only known `editor.doc.*` paths | `apps/docs/document-api/available-operations.mdx` + `DOCUMENT_API_MEMBER_PATHS` | None | Docs consistency gate | | `check-examples.ts` | check | Ensure required workflow example headings exist in `src/README.md` | `packages/document-api/src/README.md` | None | Docs workflow example gate | | `check-contract-parity.ts` | check | Enforce parity between operation IDs, command catalog, maps, and runtime API member paths | `packages/document-api/src/index.js` exports + runtime API shape | None | Contract surface integrity gate | | `generate-internal-schemas.ts` | generate | Generate internal-only operation schema snapshot | Contract snapshot + schema dialect | `packages/document-api/.generated-internal/contract-schemas/index.json` | Local tooling/debugging | diff --git a/packages/document-api/scripts/check-doc-coverage.ts b/packages/document-api/scripts/check-doc-coverage.ts deleted file mode 100644 index b45983375e..0000000000 --- a/packages/document-api/scripts/check-doc-coverage.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Purpose: Ensure every operation has a dedicated section in `src/README.md`. - * Caller: Documentation quality gate for operation-level docs. - * Reads: `packages/document-api/src/README.md` + `OPERATION_IDS`. - * Writes: None (exit code + console output only). - * Fails when: Any operation ID is missing a `### \`\`` heading. - */ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { OPERATION_IDS } from '../src/index.js'; -import { runScript } from './lib/generation-utils.js'; - -const README_PATH = resolve(process.cwd(), 'packages/document-api/src/README.md'); - -function hasOperationSection(readme: string, operationId: string): boolean { - const escaped = operationId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const sectionPattern = new RegExp(`^###\\s+\`${escaped}\`\\s*$`, 'm'); - return sectionPattern.test(readme); -} - -runScript('doc coverage check', async () => { - const readme = await readFile(README_PATH, 'utf8'); - const missing = OPERATION_IDS.filter((operationId) => !hasOperationSection(readme, operationId)); - - if (missing.length > 0) { - console.error('doc coverage check failed: missing operation sections in README.md'); - for (const operationId of missing) { - console.error(`- ${operationId}`); - } - process.exitCode = 1; - return; - } - - console.log(`doc coverage check passed (${OPERATION_IDS.length} operations documented).`); -}); diff --git a/packages/document-api/scripts/check-overview-alignment.ts b/packages/document-api/scripts/check-overview-alignment.ts index 2bb4efb37b..896ece2fa5 100644 --- a/packages/document-api/scripts/check-overview-alignment.ts +++ b/packages/document-api/scripts/check-overview-alignment.ts @@ -1,9 +1,15 @@ /** - * Purpose: Enforce required/forbidden overview content and API-surface path validity. - * Caller: Documentation consistency gate for `apps/docs/document-api/overview.mdx`. + * Purpose: Enforce structural correctness of the Document API overview page. + * Caller: Documentation consistency gate for `apps/docs/document-api/available-operations.mdx`. * Reads: Overview doc content + `DOCUMENT_API_MEMBER_PATHS`. * Writes: None (exit code + console output only). - * Fails when: Disclaimers/markers are missing, forbidden placeholders exist, or unknown API paths appear. + * Fails when: The reference link or generated section markers are missing, + * forbidden stale placeholders appear, or `editor.doc.*` paths reference + * unknown API members. + * + * NOT enforced: product-status framing (e.g. "alpha", "subject to change"). + * Those launch-phase disclaimers were removed when the Document API went + * live; this gate now focuses on durable structural correctness. */ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -18,14 +24,6 @@ import { const OVERVIEW_PATH = resolve(process.cwd(), getOverviewDocsPath()); const REQUIRED_PATTERNS = [ - { - label: 'alpha disclaimer', - pattern: /\balpha\b/i, - }, - { - label: 'subject-to-change disclaimer', - pattern: /subject to (?:breaking )?changes?/i, - }, { label: 'generated reference link', pattern: /\/document-api\/reference\/index/i, diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index f1dc0b3bde..4f9f31b40d 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -188,539 +188,16 @@ if (caps.operations['create.heading'].dryRun) { } ``` -## Operation Reference +## Operation reference -Each operation has a dedicated section below. Grouped by namespace. +Per-operation reference docs (summary, member path, mutation/idempotency flags, expected result, input / output field tables, raw schemas) are generated from the contract and live under [`apps/docs/document-api/reference/`](../../../apps/docs/document-api/reference/). The generator is unconditional: every `OPERATION_ID` produces a page. -### Core +To regenerate after changing the contract: -### `find` - -Search the document for nodes or text matching an SDM/1 selector. Returns paginated `items` where each item is an `SDNodeResult` (`{ node, address }`). - -- **Input**: `SDFindInput` -- **Output**: `SDFindResult` -- **Mutates**: No -- **Idempotency**: idempotent - -### `getNode` - -Resolve a `NodeAddress` to an `SDNodeResult` envelope with projected SDM/1 node and canonical address. Throws `TARGET_NOT_FOUND` when the address is invalid. - -- **Input**: `NodeAddress` -- **Output**: `SDNodeResult` -- **Mutates**: No -- **Idempotency**: idempotent - -### `getNodeById` - -Resolve a block node by its unique `nodeId`. Optionally constrain by `nodeType`. Throws `TARGET_NOT_FOUND` when the ID is not found. - -- **Input**: `GetNodeByIdInput` (`{ nodeId, nodeType? }`) -- **Output**: `SDNodeResult` -- **Mutates**: No -- **Idempotency**: idempotent - -### `getText` - -Return the full plaintext content of the document. - -- **Input**: `GetTextInput` (empty object) -- **Output**: `string` -- **Mutates**: No -- **Idempotency**: idempotent - -### `info` - -Return document summary metadata (block count, word count, character count). - -- **Input**: `InfoInput` (empty object) -- **Output**: `DocumentInfo` -- **Mutates**: No -- **Idempotency**: idempotent - -### `insert` - -Insert content into the document. Text input inserts at an optional `SelectionTarget` or `ref`, or appends at the end of the document when both are omitted. Structural content inserts relative to an optional `BlockNodeAddress` using `placement`. - -Supports dry-run and tracked mode. - -- **Input**: `InsertInput` (`{ value, type?, target: SelectionTarget } | { value, type?, ref: string } | { value, type? } | { content, target?: BlockNodeAddress, placement?, nestingPolicy? }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `SDMutationReceipt` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: see the generated reference docs for the full text vs. structural failure surface - -### `replace` - -Replace content at a contiguous selection. Text replacement accepts `SelectionTarget` or `ref`. Structural replacement accepts `BlockNodeAddress`, `SelectionTarget`, or `ref` with `content`. Supports dry-run and tracked mode. - -- **Input**: `ReplaceInput` (`{ target: SelectionTarget, text } | { ref: string, text } | { target: BlockNodeAddress | SelectionTarget, content, nestingPolicy? } | { ref: string, content, nestingPolicy? }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `SDMutationReceipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: see the generated reference docs for the full text vs. structural failure surface - -### `delete` - -Delete content at a contiguous selection. Accepts either an explicit `SelectionTarget` or a mutation-ready `ref`. Supports dry-run and tracked mode. - -- **Input**: `DeleteInput` (`{ target: SelectionTarget, behavior?: 'selection' | 'exact' } | { ref: string, behavior?: 'selection' | 'exact' }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `TextMutationReceipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP` - -### `blocks.delete` - -Delete an entire block node (paragraph, heading, listItem, table, image, sdt) by its `BlockNodeAddress`. Throws pre-apply errors for missing, ambiguous, or unsupported targets. Direct-only. Supports dry-run. - -- **Input**: `BlocksDeleteInput` (`{ target: BlockNodeAddress }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `BlocksDeleteResult` (`{ success: true, deleted: BlockNodeAddress }`) -- **Mutates**: Yes -- **Idempotency**: conditional -- **Throws**: `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `CAPABILITY_UNAVAILABLE`, `INVALID_TARGET`, `INTERNAL_ERROR` - -### Capabilities - -### `capabilities.get` - -Return a runtime capability snapshot describing which operations, namespaces, tracked mode, and dry-run support are available in the current editor configuration. - -- **Input**: `undefined` -- **Output**: `DocumentApiCapabilities` -- **Mutates**: No -- **Idempotency**: idempotent - -### Create - -### `create.paragraph` - -Insert a new paragraph node at a specified location (document start/end, before/after a block). Returns the new paragraph's `BlockNodeAddress` and `insertionPoint`. Supports dry-run and tracked mode. - -- **Input**: `CreateParagraphInput` (`{ at?, text? }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `CreateParagraphResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET` - -### `create.heading` - -Insert a new heading node at a specified location with a given level (1-6). Returns the new heading's `BlockNodeAddress` and `insertionPoint`. Supports dry-run and tracked mode. - -- **Input**: `CreateHeadingInput` (`{ level, at?, text? }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `CreateHeadingResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET` - -### Format - -### `format.apply` - -Apply explicit inline style changes (bold, italic, underline, strike) to a contiguous selection using directive semantics (`'on'`, `'off'`, `'clear'`). Accepts a `SelectionTarget` or `ref`. Supports dry-run and tracked mode. Availability depends on the corresponding marks being registered in the editor schema. - -- **Input**: `StyleApplyInput` (`{ target: SelectionTarget, inline: { bold?, italic?, underline?, strike? } } | { ref: string, inline: { bold?, italic?, underline?, strike? } }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `TextMutationReceipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET` - -### Lists - -### `lists.list` - -List all list items in the document, optionally filtered by `within`, `kind`, `level`, or `ordinal`. Supports pagination via `limit` and `offset`. - -- **Input**: `ListsListQuery | undefined` -- **Output**: `ListsListResult` (`{ items, total }`) -- **Mutates**: No -- **Idempotency**: idempotent - -### `lists.get` - -Retrieve full information for a single list item by its `ListItemAddress`. Throws `TARGET_NOT_FOUND` when the address is invalid. - -- **Input**: `ListsGetInput` (`{ address }`) -- **Output**: `ListItemInfo` -- **Mutates**: No -- **Idempotency**: idempotent - -### `lists.insert` - -Insert a new list item before or after a target item. Returns the new item's `ListItemAddress` and `insertionPoint`. Supports dry-run and tracked mode. - -- **Input**: `ListInsertInput` (`{ target, position, text? }`) -- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) -- **Output**: `ListsInsertResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET` - -### `lists.create` - -Create a new list from one or more paragraphs. Two modes: `empty` (convert a single paragraph at `at`) or `fromParagraphs` (convert a `BlockAddress` or `BlockRange`). Creates a new `numId` + `abstractNum` definition for the requested `kind`. Direct-only. Supports dry-run. - -- **Input**: `ListsCreateInput` (`{ mode: 'empty', at, kind, level? } | { mode: 'fromParagraphs', target, kind, level? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsCreateResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET` - -### `lists.attach` - -Attach non-list paragraphs to an existing list. Target paragraphs inherit the `attachTo` item's `numId`. Direct-only. Supports dry-run. - -- **Input**: `ListsAttachInput` (`{ target, attachTo, level? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET`, `NO_OP` - -### `lists.detach` - -Remove numbering properties from targeted list items, converting them back to plain paragraphs. Preserves text and non-list formatting. Direct-only. Supports dry-run. - -- **Input**: `ListsDetachInput` (`{ target }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsDetachResult` -- **Mutates**: Yes -- **Idempotency**: conditional (re-detach is no-op) -- **Failure codes**: `INVALID_TARGET` - -### `lists.join` - -Merge two adjacent list sequences. `withPrevious` merges the target's sequence into the preceding one; `withNext` merges the following sequence into the target's. Requires both sequences to share the same `abstractNumId`. Direct-only. Supports dry-run. - -- **Input**: `ListsJoinInput` (`{ target, direction: 'withPrevious' | 'withNext' }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsJoinResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET`, `NO_ADJACENT_SEQUENCE`, `INCOMPATIBLE_DEFINITIONS`, `ALREADY_SAME_SEQUENCE` - -### `lists.canJoin` - -Read-only preflight check for `lists.join`. Returns whether two adjacent sequences can be joined. - -- **Input**: `ListsCanJoinInput` (`{ target, direction: 'withPrevious' | 'withNext' }`) -- **Output**: `ListsCanJoinResult` (`{ canJoin, reason?, adjacentListId? }`) -- **Mutates**: No -- **Idempotency**: idempotent - -### `lists.separate` - -Split a list sequence at the target item. Creates a new `numId` pointing to the same `abstractNumId`. Items from target through end of sequence are reassigned to the new `numId`. Direct-only. Supports dry-run. - -- **Input**: `ListsSeparateInput` (`{ target, copyOverrides? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsSeparateResult` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET`, `NO_OP` - -### `lists.setLevel` - -Set the absolute indent level (0โ€“8) of a list item. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelInput` (`{ target, level }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `NO_OP` - -### `lists.indent` - -Increase the indent level of a list item by one. Convenience wrapper for `setLevel(current + 1)`. Direct-only. Supports dry-run. - -- **Input**: `ListTargetInput` (`{ target }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` - -### `lists.outdent` - -Decrease the indent level of a list item by one. Convenience wrapper for `setLevel(current - 1)`. Direct-only. Supports dry-run. - -- **Input**: `ListTargetInput` (`{ target }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` - -### `lists.setValue` - -Set the numbering start value at the target item's position. Pass `value: null` to remove a previously set override. Mid-sequence targets atomically separate then set `startOverride`. Direct-only. Supports dry-run. - -- **Input**: `ListsSetValueInput` (`{ target, value: number | null }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET`, `NO_OP` - -### `lists.continuePrevious` - -Continue numbering from the nearest previous compatible list sequence (same `abstractNumId`). Merges the target's sequence into that previous sequence's `numId`. Direct-only. Supports dry-run. - -- **Input**: `ListsContinuePreviousInput` (`{ target }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET`, `NO_COMPATIBLE_PREVIOUS`, `ALREADY_CONTINUOUS` - -### `lists.canContinuePrevious` - -Read-only preflight check for `lists.continuePrevious`. Returns whether a compatible previous sequence exists. - -- **Input**: `ListsCanContinuePreviousInput` (`{ target }`) -- **Output**: `ListsCanContinuePreviousResult` (`{ canContinue, reason?, previousListId? }`) -- **Mutates**: No -- **Idempotency**: idempotent - -### `lists.setLevelRestart` - -Set the `lvlRestart` behavior for a specified level. Controls when the level's counter resets. `scope: 'definition'` mutates the abstract (affects all instances); `scope: 'instance'` uses `lvlOverride` (affects only this `numId`). Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelRestartInput` (`{ target, level, restartAfterLevel: number | null, scope? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` - -### `lists.convertToText` - -Convert list items to plain paragraphs. When `includeMarker` is true, prepends the rendered marker text to paragraph content before clearing numbering properties. Direct-only. Supports dry-run. - -- **Input**: `ListsConvertToTextInput` (`{ target, includeMarker? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsConvertToTextResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_TARGET` - -### `lists.applyTemplate` - -Apply a captured `ListTemplate` to the target list's abstract definition, optionally filtered to specific levels. Direct-only. Supports dry-run. - -- **Input**: `ListsApplyTemplateInput` (`{ target, template, levels? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `INVALID_INPUT` - -### `lists.applyPreset` - -Apply a built-in list formatting preset (e.g. `decimal`, `disc`, `upperRoman`) to the target list. Direct-only. Supports dry-run. - -- **Input**: `ListsApplyPresetInput` (`{ target, preset, levels? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `INVALID_INPUT` - -### `lists.captureTemplate` - -Capture the formatting of a list as a reusable `ListTemplate`. Read-only operation. - -- **Input**: `ListsCaptureTemplateInput` (`{ target, levels? }`) -- **Output**: `ListsCaptureTemplateResult` (`{ success, template }` | `{ success: false, failure }`) -- **Mutates**: No -- **Idempotency**: idempotent -- **Failure codes**: `INVALID_TARGET`, `INVALID_INPUT` - -### `lists.setLevelNumbering` - -Set the numbering format (`numFmt`), pattern (`lvlText`), and optional start value for a specific list level. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelNumberingInput` (`{ target, level, numFmt, lvlText, start? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` - -### `lists.setLevelBullet` - -Set the bullet marker text for a specific list level. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelBulletInput` (`{ target, level, markerText }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` - -### `lists.setLevelPictureBullet` - -Set a picture bullet for a specific list level by its OOXML `lvlPicBulletId`. Requires picture bullet pipeline support. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelPictureBulletInput` (`{ target, level, pictureBulletId }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND`, `INVALID_INPUT`, `CAPABILITY_UNAVAILABLE` - -### `lists.setLevelAlignment` - -Set the marker alignment (`left`, `center`, `right`) for a specific list level. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelAlignmentInput` (`{ target, level, alignment }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` - -### `lists.setLevelIndents` - -Set the paragraph indentation values (`left`, `hanging`, `firstLine`) for a specific list level. At least one property required; `hanging` and `firstLine` are mutually exclusive. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelIndentsInput` (`{ target, level, left?, hanging?, firstLine? }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND`, `INVALID_INPUT` - -### `lists.setLevelTrailingCharacter` - -Set the trailing character (`tab`, `space`, `nothing`) after the marker for a specific list level. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelTrailingCharacterInput` (`{ target, level, trailingCharacter }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` - -### `lists.setLevelMarkerFont` - -Set the font family used for the marker character at a specific list level. Direct-only. Supports dry-run. - -- **Input**: `ListsSetLevelMarkerFontInput` (`{ target, level, fontFamily }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE`, `LEVEL_NOT_FOUND` - -### `lists.clearLevelOverrides` - -Remove instance-level overrides (`lvlOverride`) for a specific list level, restoring abstract definition values. Direct-only. Supports dry-run. - -- **Input**: `ListsClearLevelOverridesInput` (`{ target, level }`) -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `LEVEL_OUT_OF_RANGE` - -### `lists.setType` - -Compound operation that converts a list to ordered/bullet and merges adjacent compatible sequences to preserve continuous numbering (SD-2052). - -- **Input**: `ListsSetTypeInput` โ€” `{ target, kind: 'ordered' | 'bullet', continuity?: 'preserve' | 'none' }` -- **Options**: `MutationOptions` (`{ dryRun? }`) -- **Output**: `ListsMutateItemResult` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `INVALID_TARGET`, `INVALID_INPUT` - -### Comments - -### `comments.create` - -Create a new comment thread or reply. When `parentCommentId` is provided, creates a reply. Otherwise creates a root comment anchored to the given text range. - -- **Input**: `CommentsCreateInput` (`{ text, target?, parentCommentId? }`) -- **Output**: `Receipt` -- **Mutates**: Yes -- **Idempotency**: non-idempotent -- **Failure codes**: `INVALID_TARGET` - -### `comments.patch` - -Field-level patch on an existing comment. Exactly one mutation field must be provided per call. - -- **Input**: `CommentsPatchInput` (`{ commentId, text?, target?, status?, isInternal? }`) -- **Output**: `Receipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `INVALID_INPUT`, `INVALID_TARGET`, `NO_OP` - -### `comments.delete` - -Remove a comment from the document. - -- **Input**: `CommentsDeleteInput` (`{ commentId }`) -- **Output**: `Receipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP` - -### `comments.get` - -Retrieve full information for a single comment by ID. Throws `TARGET_NOT_FOUND` when the comment is not found. - -- **Input**: `GetCommentInput` (`{ commentId }`) -- **Output**: `CommentInfo` -- **Mutates**: No -- **Idempotency**: idempotent - -### `comments.list` - -List all comments in the document. Optionally include resolved comments. - -- **Input**: `CommentsListQuery | undefined` (`{ includeResolved? }`) -- **Output**: `CommentsListResult` (`{ items, total }`) -- **Mutates**: No -- **Idempotency**: idempotent - -### Track Changes - -### `trackChanges.list` - -List tracked changes in the document. Supports filtering by `type`, pagination via `limit`/`offset`, and story scoping via `in`. - -- **Input**: `TrackChangesListInput | undefined` (`{ limit?, offset?, type?, in?: StoryLocator | 'all' }`) -- **Output**: `TrackChangesListResult` (`{ items, total }`) -- **Mutates**: No -- **Idempotency**: idempotent - -### `trackChanges.get` - -Retrieve full information for a single tracked change by its canonical ID. Include `story` for non-body changes. Throws `TARGET_NOT_FOUND` when the ID is invalid. - -- **Input**: `TrackChangesGetInput` (`{ id, story? }`) -- **Output**: `TrackChangeInfo` (includes `wordRevisionIds` with raw imported Word OOXML `w:id` values when available) -- **Mutates**: No -- **Idempotency**: idempotent - -### `trackChanges.decide` +``` +pnpm run docapi:sync +``` -Accept or reject a tracked change by ID, or accept/reject all changes with `{ scope: 'all' }`. Include `story` when the change lives outside the body. +`docapi:check` (`check-contract-outputs`) gates that the generated reference matches the contract; do not hand-edit the generated `.mdx` files. -- **Input**: `ReviewDecideInput` (`{ decision: 'accept' | 'reject', target: { id, story? } | { scope: 'all' } }`) -- **Output**: `Receipt` -- **Mutates**: Yes -- **Idempotency**: conditional -- **Failure codes**: `NO_OP`, `TARGET_NOT_FOUND` +The previous catalog-style "Operation Reference" section was removed because it could only ever cover a small subset of the 403 operations and duplicated the generated docs. The generated reference is the source of truth. diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index b51b8d9550..7920d9b8d0 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -397,7 +397,7 @@ export const INTENT_GROUP_META: Record = { toolName: 'superdoc_comment', description: 'Manage document comment threads: create, read, update, and delete. ' + - 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target: {kind:"text", blockId:"", range:{start:, end:}} using the blockId and highlightRange from the search result. ' + + 'To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). ' + 'For threaded replies, pass "parentId" with the parent comment ID. ' + 'Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). ' + 'Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. ' + @@ -1456,7 +1456,8 @@ export const OPERATION_DEFINITIONS = { }, 'format.paragraph.setAlignment': { memberPath: 'format.paragraph.setAlignment', - description: 'Set paragraph alignment (justification) on a paragraph-like block.', + description: + 'Set visual paragraph alignment on a paragraph-like block. For RTL paragraphs, left/right are translated to Word-compatible stored justification values.', expectedResult: 'Returns a ParagraphMutationResult; reports NO_OP if the alignment already matches.', requiresDocumentContext: true, metadata: mutationOperation({ diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 284ed3a99f..c0a919e361 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3398,10 +3398,17 @@ const operationSchemas: Record = { failure: paragraphMutationFailureSchemaFor('format.paragraph.resetDirectFormatting'), }, 'format.paragraph.setAlignment': { - input: objectSchema({ target: paragraphTargetSchema, alignment: { enum: [...PARAGRAPH_ALIGNMENTS] } }, [ - 'target', - 'alignment', - ]), + input: objectSchema( + { + target: paragraphTargetSchema, + alignment: { + enum: [...PARAGRAPH_ALIGNMENTS], + description: + "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side.", + }, + }, + ['target', 'alignment'], + ), output: paragraphMutationResultSchemaFor('format.paragraph.setAlignment'), success: paragraphMutationSuccessSchema, failure: paragraphMutationFailureSchemaFor('format.paragraph.setAlignment'), diff --git a/packages/document-api/src/customXml/customXml.test.ts b/packages/document-api/src/customXml/customXml.test.ts index 4106179f9c..a75970f132 100644 --- a/packages/document-api/src/customXml/customXml.test.ts +++ b/packages/document-api/src/customXml/customXml.test.ts @@ -13,7 +13,12 @@ function makeAdapter(): CustomXmlPartsAdapter { return { list: mock().mockReturnValue({ items: [], total: 0 }), get: mock().mockReturnValue(null), - create: mock().mockReturnValue({ success: true, id: '{X}', partName: 'customXml/item1.xml', propsPartName: 'customXml/itemProps1.xml' }), + create: mock().mockReturnValue({ + success: true, + id: '{X}', + partName: 'customXml/item1.xml', + propsPartName: 'customXml/itemProps1.xml', + }), patch: mock().mockReturnValue({ success: true, target: { id: '{X}' } }), remove: mock().mockReturnValue({ success: true, target: { id: '{X}' } }), }; @@ -93,9 +98,7 @@ describe('customXml.parts target validation', () => { it('rejects target with empty partName', () => { const adapter = makeAdapter(); - expect(() => executeCustomXmlPartsGet(adapter, { target: { partName: '' } })).toThrow( - DocumentApiValidationError, - ); + expect(() => executeCustomXmlPartsGet(adapter, { target: { partName: '' } })).toThrow(DocumentApiValidationError); }); it('rejects target with BOTH id and partName', () => { diff --git a/packages/document-api/src/customXml/customXml.ts b/packages/document-api/src/customXml/customXml.ts index 2617a8bb93..3d1567cdfe 100644 --- a/packages/document-api/src/customXml/customXml.ts +++ b/packages/document-api/src/customXml/customXml.ts @@ -95,10 +95,7 @@ function validateContent(content: unknown, operationName: string): asserts conte function validateSchemaRefs(schemaRefs: unknown, operationName: string): asserts schemaRefs is string[] { if (!Array.isArray(schemaRefs)) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `${operationName} 'schemaRefs' must be an array of strings.`, - ); + throw new DocumentApiValidationError('INVALID_INPUT', `${operationName} 'schemaRefs' must be an array of strings.`); } for (const [i, entry] of schemaRefs.entries()) { if (typeof entry !== 'string' || entry.length === 0) { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 023e4e5e40..dfb4f2470a 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2726,9 +2726,7 @@ describe('DomPainter', () => { const measure: Measure = { kind: 'paragraph', - lines: [ - { fromRun: 0, fromChar: 0, toRun: 2, toChar: 7, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, - ], + lines: [{ fromRun: 0, fromChar: 0, toRun: 2, toChar: 7, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20, }; diff --git a/packages/layout-engine/style-engine/src/cascade.test.ts b/packages/layout-engine/style-engine/src/cascade.test.ts index 9da8ecaa51..4cf8f6de72 100644 --- a/packages/layout-engine/style-engine/src/cascade.test.ts +++ b/packages/layout-engine/style-engine/src/cascade.test.ts @@ -189,10 +189,7 @@ describe('cascade - combineRunProperties', () => { }); it('drops concrete `cs` from lower when higher supplies `cstheme` (lowercase)', () => { - const result = combineRunProperties([ - { fontFamily: { cs: 'Arial' } }, - { fontFamily: { cstheme: 'majorBidi' } }, - ]); + const result = combineRunProperties([{ fontFamily: { cs: 'Arial' } }, { fontFamily: { cstheme: 'majorBidi' } }]); expect(result.fontFamily).toEqual({ cstheme: 'majorBidi' }); }); @@ -221,10 +218,7 @@ describe('cascade - combineRunProperties', () => { }); it('drops theme `cstheme` from lower when higher supplies concrete `cs`', () => { - const result = combineRunProperties([ - { fontFamily: { cstheme: 'majorBidi' } }, - { fontFamily: { cs: 'Arial' } }, - ]); + const result = combineRunProperties([{ fontFamily: { cstheme: 'majorBidi' } }, { fontFamily: { cs: 'Arial' } }]); expect(result.fontFamily).toEqual({ cs: 'Arial' }); }); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index e894b5a955..7cb3a680dc 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -5,12 +5,7 @@ * This module is format-aware (docx), but translator-agnostic. */ -import { - combineIndentProperties, - combineProperties, - combineRunProperties, - FONT_SLOT_THEME_PAIRS, -} from '../cascade.js'; +import { combineIndentProperties, combineProperties, combineRunProperties, FONT_SLOT_THEME_PAIRS } from '../cascade.js'; import type { PropertyObject } from '../cascade.js'; import type { ParagraphConditionalFormatting, ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts'; import type { NumberingProperties } from './numbering-types.ts'; diff --git a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts index cc0c0f64c5..047de95d6c 100644 --- a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts +++ b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; const REPO_ROOT = path.resolve(import.meta.dir, '../../../../../'); const CONTRACT_PATH = path.join(REPO_ROOT, 'apps/cli/generated/sdk-contract.json'); const CATALOG_PATH = path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json'); +const NODE_CLIENT_PATH = path.join(REPO_ROOT, 'packages/sdk/langs/node/src/generated/client.ts'); const CLI_ONLY_OPERATIONS = new Set([ 'doc.open', 'doc.save', @@ -24,6 +25,16 @@ async function loadJson(filePath: string): Promise { return JSON.parse(await readFile(filePath, 'utf8')) as T; } +async function loadBoundNodeClientSource(): Promise { + const source = await readFile(NODE_CLIENT_PATH, 'utf8'); + const marker = 'export function createBoundDocApi(runtime: RuntimeInvoker) {'; + const start = source.indexOf(marker); + if (start === -1) { + throw new Error('Unable to locate createBoundDocApi in generated Node client.'); + } + return source.slice(start); +} + type Contract = { contractVersion: string; sourceHash: string; @@ -179,6 +190,16 @@ describe('Contract integrity', () => { } } }); + + test('all document-surface operations are projected onto the bound Node client', async () => { + contract = await loadJson(CONTRACT_PATH); + const boundSource = await loadBoundNodeClientSource(); + + for (const [id, op] of Object.entries(contract.operations)) { + if (op.sdkSurface !== 'document') continue; + expect(boundSource.includes(`CONTRACT.operations["${id}"]`)).toBe(true); + } + }); }); describe('Intent tool catalog integrity', () => { diff --git a/packages/sdk/langs/browser/src/system-prompt.ts b/packages/sdk/langs/browser/src/system-prompt.ts index c5405691d7..af0d99b5c4 100644 --- a/packages/sdk/langs/browser/src/system-prompt.ts +++ b/packages/sdk/langs/browser/src/system-prompt.ts @@ -371,7 +371,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) \`\`\` diff --git a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts index e28058eb37..3464ba0a43 100644 --- a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts +++ b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { BoundDocApi } from '../generated/client.js'; -import { SuperDocDocument } from '../index.ts'; +import { SuperDocClient, SuperDocDocument } from '../index.ts'; import { SuperDocCliError } from '../runtime/errors.js'; import { dispatchSuperDocTool } from '../tools.ts'; @@ -20,6 +20,29 @@ describe('SuperDocDocument', () => { }); }); +describe('SuperDocClient handle lifecycle', () => { + test('invoke after close throws DOCUMENT_CLOSED with the attempted operation id', async () => { + const client = new SuperDocClient({ env: { SUPERDOC_CLI_BIN: '/tmp/fake-cli' } }); + // Bypass the real CLI subprocess by stubbing the internal runtime and rawApi. + (client as any).runtime = { invoke: async () => ({}) }; + (client as any).rawApi = { open: async () => ({ contextId: 'session-1' }) }; + + const doc = await client.open({} as any); + await doc.close(); + + try { + await doc.save(); + throw new Error('Expected doc.save() to throw on a closed handle.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + const cliError = error as SuperDocCliError; + expect(cliError.code).toBe('DOCUMENT_CLOSED'); + expect(cliError.message).toContain('doc.save'); + expect(cliError.details).toEqual({ sessionId: 'session-1', operationId: 'doc.save' }); + } + }); +}); + describe('dispatchSuperDocTool', () => { test('dispatches against root-bound document methods', async () => { const calls: unknown[] = []; diff --git a/packages/sdk/langs/node/src/__tests__/request-timeout-ms.e2e.test.ts b/packages/sdk/langs/node/src/__tests__/request-timeout-ms.e2e.test.ts new file mode 100644 index 0000000000..b003c044d3 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/request-timeout-ms.e2e.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createSuperDocClient } from '../index.ts'; +import { SuperDocCliError } from '../runtime/errors.js'; + +// Repo root: packages/sdk/langs/node/src/__tests__ โ†’ ../../../../../../ +const REPO_ROOT = path.resolve(import.meta.dir, '../../../../../..'); +const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); +const FIXTURE_DOC = path.join(REPO_ROOT, 'packages/super-editor/src/editors/v1/tests/data/advanced-text.docx'); + +const E2E_TIMEOUT_MS = 30_000; + +describe('SDK requestTimeoutMs propagation (e2e)', () => { + const cleanup: string[] = []; + + beforeAll(() => { + // Sanity-check the workspace layout once so test failures are clear when + // the fixture moves or the CLI source is renamed. + expect(CLI_BIN.endsWith('apps/cli/src/index.ts')).toBe(true); + }); + + afterEach(async () => { + while (cleanup.length > 0) { + const dir = cleanup.pop(); + if (dir) await rm(dir, { recursive: true, force: true }); + } + }); + + test( + 'client.requestTimeoutMs is honored by the spawned host on a real cli.invoke', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-sdk-timeout-e2e-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); + + // 1ms is well below any real `open` wall time. With the fix, the host + // receives `--request-timeout-ms 1` at spawn and kills the invoke; + // before the fix the SDK option never reached the host and the + // operation would run to completion against the host's 30s default. + const client = createSuperDocClient({ + env: { + SUPERDOC_CLI_BIN: CLI_BIN, + SUPERDOC_CLI_STATE_DIR: stateDir, + }, + // 1ms host ceiling. The JS watchdog defaults to 30s, and + // `resolveWatchdogTimeout` widens it above `requestTimeoutMs` anyway, + // so the host's structured RequestTimeout error wins the race. + requestTimeoutMs: 1, + }); + + try { + await client.connect(); + + let caught: unknown; + try { + await client.open({ doc: FIXTURE_DOC }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SuperDocCliError); + const err = caught as SuperDocCliError; + expect(err.code).toBe('TIMEOUT'); + const details = err.details as { timeoutMs?: number } | undefined; + expect(details?.timeoutMs).toBe(1); + } finally { + await client.dispose(); + } + }, + E2E_TIMEOUT_MS, + ); +}); diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index 9fbdaa1cb7..632e84fcbd 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -45,9 +45,9 @@ class BoundRuntime implements RuntimeInvoker { options: InvokeOptions = {}, ): Promise { if (this.closed) { - throw new SuperDocCliError('Document handle is closed.', { + throw new SuperDocCliError(`Document handle is closed; cannot invoke ${operation.operationId}.`, { code: 'DOCUMENT_CLOSED', - details: { sessionId: this.sessionId }, + details: { sessionId: this.sessionId, operationId: operation.operationId }, }); } return this.runtime.invoke(operation, { ...params, sessionId: this.sessionId }, options); diff --git a/packages/sdk/langs/node/src/runtime/__tests__/host-spawn-args.test.ts b/packages/sdk/langs/node/src/runtime/__tests__/host-spawn-args.test.ts new file mode 100644 index 0000000000..5f671b7d59 --- /dev/null +++ b/packages/sdk/langs/node/src/runtime/__tests__/host-spawn-args.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import { buildHostSpawnArgs } from '../host.js'; + +describe('buildHostSpawnArgs', () => { + test('omits --request-timeout-ms when requestTimeoutMs is unset', () => { + expect(buildHostSpawnArgs([], {})).toEqual(['host', '--stdio']); + }); + + test('omits --request-timeout-ms when requestTimeoutMs is undefined', () => { + expect(buildHostSpawnArgs([], { requestTimeoutMs: undefined })).toEqual(['host', '--stdio']); + }); + + test('forwards requestTimeoutMs as separate argv tokens', () => { + expect(buildHostSpawnArgs([], { requestTimeoutMs: 120000 })).toEqual([ + 'host', + '--stdio', + '--request-timeout-ms', + '120000', + ]); + }); + + test('preserves prefixArgs (e.g. when the binary is a .js wrapped by node)', () => { + expect(buildHostSpawnArgs(['/path/to/cli.js'], { requestTimeoutMs: 60000 })).toEqual([ + '/path/to/cli.js', + 'host', + '--stdio', + '--request-timeout-ms', + '60000', + ]); + }); + + test('forwards requestTimeoutMs=0 as "0" so the host can reject it', () => { + // Validation lives in the host parser (positive-finite-number check). The + // SDK forwards verbatim and lets the host produce a structured error. + expect(buildHostSpawnArgs([], { requestTimeoutMs: 0 })).toEqual(['host', '--stdio', '--request-timeout-ms', '0']); + }); + + test('forwards a positive non-integer (float) verbatim', () => { + expect(buildHostSpawnArgs([], { requestTimeoutMs: 1500.5 })).toEqual([ + 'host', + '--stdio', + '--request-timeout-ms', + '1500.5', + ]); + }); +}); diff --git a/packages/sdk/langs/node/src/runtime/__tests__/resolve-watchdog-timeout.test.ts b/packages/sdk/langs/node/src/runtime/__tests__/resolve-watchdog-timeout.test.ts new file mode 100644 index 0000000000..31cbe9b960 --- /dev/null +++ b/packages/sdk/langs/node/src/runtime/__tests__/resolve-watchdog-timeout.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveJsWatchdogTimeout } from '../host.js'; + +// Must stay in sync with the constants in host.ts. Duplicated here on +// purpose โ€” the constants aren't exported, and the test should fail if the +// production headroom drifts unintentionally. +const HOST_DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const WATCHDOG_HEADROOM_MS = 5_000; + +describe('resolveJsWatchdogTimeout', () => { + test('defaults: widens above the host ceiling so host TIMEOUT wins the race', () => { + // Regression guard for the PR-3369 review finding `default-watchdog-race`: + // pre-fix, with neither option set both sides ran 30s timers and the JS + // watchdog could fire first, surfacing the legacy error string. + const watchdog = resolveJsWatchdogTimeout(30_000, undefined, undefined); + expect(watchdog).toBeGreaterThanOrEqual(HOST_DEFAULT_REQUEST_TIMEOUT_MS + WATCHDOG_HEADROOM_MS); + }); + + test('honors an explicit watchdogTimeoutMs higher than the default', () => { + // If the caller deliberately raised the watchdog above what we'd derive, + // keep their value. + expect(resolveJsWatchdogTimeout(120_000, undefined, undefined)).toBe(120_000); + }); + + test('widens above an explicit requestTimeoutMs', () => { + expect(resolveJsWatchdogTimeout(30_000, 60_000, undefined)).toBe(60_000 + WATCHDOG_HEADROOM_MS); + }); + + test('keeps the larger of watchdogTimeoutMs and (requestTimeoutMs + headroom)', () => { + expect(resolveJsWatchdogTimeout(200_000, 60_000, undefined)).toBe(200_000); + }); + + test('widens above a per-call timeoutMs override', () => { + expect(resolveJsWatchdogTimeout(30_000, undefined, 90_000)).toBe(90_000 + WATCHDOG_HEADROOM_MS); + }); + + test('per-call override wins over client-level requestTimeoutMs', () => { + // The per-call value reflects an intent specific to this invoke; honor it. + expect(resolveJsWatchdogTimeout(30_000, 60_000, 120_000)).toBe(120_000 + WATCHDOG_HEADROOM_MS); + }); +}); diff --git a/packages/sdk/langs/node/src/runtime/host.ts b/packages/sdk/langs/node/src/runtime/host.ts index 6bb6b35e0d..1d9f5cb4d6 100644 --- a/packages/sdk/langs/node/src/runtime/host.ts +++ b/packages/sdk/langs/node/src/runtime/host.ts @@ -40,6 +40,63 @@ const FORWARD_HOST_STDERR = const JSON_RPC_TIMEOUT_CODE = -32011; +// Mirrors apps/cli/src/host/server.ts:DEFAULT_REQUEST_TIMEOUT_MS. Kept in sync +// by hand; an explicit constant here avoids importing CLI app internals across +// the package boundary. +const HOST_DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +// Extra time the JS-side watchdog waits beyond the host-side ceiling so the +// host's structured RequestTimeout error wins the race against the SDK's own +// abort. The buffer absorbs JSON-RPC serialization, stdio drain, and event- +// loop latency. +const WATCHDOG_HEADROOM_MS = 5_000; + +/** + * Builds the argv passed to `spawn` for `superdoc host --stdio`. Propagates + * `requestTimeoutMs` to the host via `--request-timeout-ms`, since the SDK + * option alone cannot raise the host's 30s per-invoke ceiling otherwise. + * + * Exported for unit testing. + */ +export function buildHostSpawnArgs(prefixArgs: readonly string[], options: { requestTimeoutMs?: number }): string[] { + const args = [...prefixArgs, 'host', '--stdio']; + if (options.requestTimeoutMs != null) { + args.push('--request-timeout-ms', String(options.requestTimeoutMs)); + } + return args; +} + +/** + * Computes the JS-side watchdog timeout for a single JSON-RPC request. + * + * The watchdog must outlive the host-side `cli.invoke` ceiling so the host's + * structured `RequestTimeout` JSON-RPC frame wins the race against the SDK's + * own abort. Three cases: + * + * 1. A per-call `InvokeOptions.timeoutMs` is supplied โ†’ widen above it. + * 2. The client set `requestTimeoutMs` โ†’ widen above that. + * 3. Neither is set โ†’ widen above the host's compiled-in default so the + * default-config case doesn't race at 30s (which previously surfaced the + * old "Host watchdog timed out" string instead of the host's structured + * TIMEOUT error). + * + * Exported for unit testing. + */ +export function resolveJsWatchdogTimeout( + watchdogTimeoutMs: number, + requestTimeoutMs: number | undefined, + timeoutMsOverride: number | undefined, +): number { + if (timeoutMsOverride != null) { + return Math.max(watchdogTimeoutMs, timeoutMsOverride + WATCHDOG_HEADROOM_MS); + } + + if (requestTimeoutMs != null) { + return Math.max(watchdogTimeoutMs, requestTimeoutMs + WATCHDOG_HEADROOM_MS); + } + + return Math.max(watchdogTimeoutMs, HOST_DEFAULT_REQUEST_TIMEOUT_MS + WATCHDOG_HEADROOM_MS); +} + /** * Transport that communicates with a long-lived CLI host process over JSON-RPC stdio. */ @@ -170,7 +227,7 @@ export class HostTransport { private async startHostProcess(): Promise { const { command, prefixArgs } = resolveInvocation(this.cliBin); - const args = [...prefixArgs, 'host', '--stdio']; + const args = buildHostSpawnArgs(prefixArgs, { requestTimeoutMs: this.requestTimeoutMs }); const child = spawn(command, args, { env: { @@ -282,15 +339,7 @@ export class HostTransport { } private resolveWatchdogTimeout(timeoutMsOverride: number | undefined): number { - if (timeoutMsOverride != null) { - return Math.max(this.watchdogTimeoutMs, timeoutMsOverride + 1_000); - } - - if (this.requestTimeoutMs != null) { - return Math.max(this.watchdogTimeoutMs, this.requestTimeoutMs + 1_000); - } - - return this.watchdogTimeoutMs; + return resolveJsWatchdogTimeout(this.watchdogTimeoutMs, this.requestTimeoutMs, timeoutMsOverride); } private async sendJsonRpcRequest(method: string, params: unknown, watchdogTimeoutMs: number): Promise { diff --git a/packages/sdk/langs/node/src/runtime/transport-common.ts b/packages/sdk/langs/node/src/runtime/transport-common.ts index eb278ac8ea..97b641d001 100644 --- a/packages/sdk/langs/node/src/runtime/transport-common.ts +++ b/packages/sdk/langs/node/src/runtime/transport-common.ts @@ -43,7 +43,25 @@ export interface SuperDocClientOptions { env?: Record; startupTimeoutMs?: number; shutdownTimeoutMs?: number; + /** + * Upper bound (ms) on how long the host process may spend on a single + * `cli.invoke` request before it kills the operation and returns a + * `RequestTimeout` error. Propagated to the host via `--request-timeout-ms` + * at spawn. Raise this for documents that legitimately need more than 30s + * to process; the SDK widens its own JSON-RPC watchdog to match. + * + * Defaults to the host's own default (30s) when unset. + */ requestTimeoutMs?: number; + /** + * JS-side watchdog (ms) the SDK waits for a host reply before giving up. + * Independent of {@link requestTimeoutMs} (which controls the host-side + * operation budget). Most callers should leave this at its default and use + * {@link requestTimeoutMs} as the single operation-timeout knob โ€” + * `resolveWatchdogTimeout` already widens the watchdog above the host + * ceiling automatically. Override only when you need to detect a hung or + * crashed host faster than the operation budget allows. + */ watchdogTimeoutMs?: number; maxQueueDepth?: number; defaultChangeMode?: ChangeMode; diff --git a/packages/sdk/tools/prompt-templates/system-prompt-core.md b/packages/sdk/tools/prompt-templates/system-prompt-core.md index f58224266c..7cba4ad117 100644 --- a/packages/sdk/tools/prompt-templates/system-prompt-core.md +++ b/packages/sdk/tools/prompt-templates/system-prompt-core.md @@ -365,7 +365,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/sdk/tools/system-prompt-mcp.md b/packages/sdk/tools/system-prompt-mcp.md index 4d3dbcfd7b..20f5518d31 100644 --- a/packages/sdk/tools/system-prompt-mcp.md +++ b/packages/sdk/tools/system-prompt-mcp.md @@ -414,7 +414,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/sdk/tools/system-prompt.md b/packages/sdk/tools/system-prompt.md index 35c44db92e..5f0fef82a0 100644 --- a/packages/sdk/tools/system-prompt.md +++ b/packages/sdk/tools/system-prompt.md @@ -369,7 +369,7 @@ superdoc_search({select: {type: "text", pattern: "target phrase"}, require: "fir superdoc_comment({ action: "create", text: "Please review this section.", - target: {kind: "text", blockId: "", range: {start: , end: }} + target: {kind: "text", blockId: "", range: {start: , end: }} }) ``` diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 5f5a66670f..4c79b9e8bc 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -28,7 +28,7 @@ import { insertTableOfContentsAtSelection } from '@extensions/table-of-contents/ * A callback function that's executed when a toolbar button is clicked * @param {CommandItem} params - Command parameters * @param {ToolbarItem} params.item - An instance of the useToolbarItem composable - * @param {*} [params.argument] - The argument passed to the command + * @param {unknown} [params.argument] - The argument passed to the command */ /** @@ -53,76 +53,57 @@ import { insertTableOfContentsAtSelection } from '@extensions/table-of-contents/ /** * @typedef {Object} ToolbarItem - * @property {Object} id - The unique ID of the toolbar item - * @property {string} id.value - The value of the ID - * @property {Object} name - The name of the toolbar item - * @property {string} name.value - The value of the name + * + * Reactive toolbar item wrapper produced by `useToolbarItem`. Each + * field is a Vue `Ref`-shaped container with a `.value`. The `.value` + * types are tightened here (was `*` / `any`) so consumers configuring + * custom toolbar buttons get real IntelliSense on the values they + * pass through. + * + * @property {{ value: string }} id - The unique ID of the toolbar item + * @property {{ value: string }} name - The name of the toolbar item * @property {string} type - The type of toolbar item (button, options, separator, dropdown, overflow) - * @property {Object} group - The group the item belongs to - * @property {string} group.value - The value of the group + * @property {{ value: string }} group - The group the item belongs to * @property {string|CommandCallback} command - The command to execute * @property {string} [noArgumentCommand] - The command to execute when no argument is provided - * @property {Object} icon - The icon for the item - * @property {*} icon.value - The value of the icon - * @property {Object} tooltip - The tooltip for the item - * @property {*} tooltip.value - The value of the tooltip + * @property {{ value: string | undefined }} icon - The icon for the item (icon-name string) + * @property {{ value: string | undefined }} tooltip - The tooltip text * @property {boolean} [restoreEditorFocus] - Whether to restore editor focus after command execution - * @property {Object} attributes - Additional attributes for the item - * @property {Object} attributes.value - The value of the attributes - * @property {Object} disabled - Whether the item is disabled - * @property {boolean} disabled.value - The value of disabled - * @property {Object} active - Whether the item is active - * @property {boolean} active.value - The value of active - * @property {Object} expand - Whether the item is expanded - * @property {boolean} expand.value - The value of expand - * @property {Object} nestedOptions - Nested options for the item - * @property {Array} nestedOptions.value - The array of nested options - * @property {Object} style - Custom style for the item - * @property {*} style.value - The value of the style - * @property {Object} isNarrow - Whether the item has narrow styling - * @property {boolean} isNarrow.value - The value of isNarrow - * @property {Object} isWide - Whether the item has wide styling - * @property {boolean} isWide.value - The value of isWide - * @property {Object} minWidth - Minimum width of the item - * @property {*} minWidth.value - The value of minWidth - * @property {Object} argument - The argument to pass to the command - * @property {*} argument.value - The value of the argument - * @property {Object} parentItem - The parent of this item if nested - * @property {*} parentItem.value - The value of parentItem - * @property {Object} childItem - The child of this item if it has one - * @property {*} childItem.value - The value of childItem - * @property {Object} iconColor - The color of the icon - * @property {*} iconColor.value - The value of iconColor - * @property {Object} hasCaret - Whether the item has a dropdown caret - * @property {boolean} hasCaret.value - The value of hasCaret - * @property {Object} dropdownStyles - Custom styles for dropdown - * @property {*} dropdownStyles.value - The value of dropdownStyles - * @property {Object} tooltipVisible - Whether the tooltip is visible - * @property {boolean} tooltipVisible.value - The value of tooltipVisible - * @property {Object} tooltipTimeout - Timeout for the tooltip - * @property {*} tooltipTimeout.value - The value of tooltipTimeout - * @property {Object} defaultLabel - The default label for the item - * @property {*} defaultLabel.value - The value of the default label - * @property {Object} label - The label for the item - * @property {*} label.value - The value of the label - * @property {Object} hideLabel - Whether to hide the label - * @property {boolean} hideLabel.value - The value of hideLabel - * @property {Object} inlineTextInputVisible - Whether inline text input is visible - * @property {boolean} inlineTextInputVisible.value - The value of inlineTextInputVisible - * @property {Object} hasInlineTextInput - Whether the item has inline text input - * @property {boolean} hasInlineTextInput.value - The value of hasInlineTextInput - * @property {Object} markName - The name of the mark - * @property {*} markName.value - The value of markName - * @property {Object} labelAttr - The attribute for the label - * @property {*} labelAttr.value - The value of labelAttr - * @property {Object} allowWithoutEditor - Whether the item can be used without an editor - * @property {boolean} allowWithoutEditor.value - The value of allowWithoutEditor - * @property {Object} dropdownValueKey - The key for dropdown value - * @property {*} dropdownValueKey.value - The value of dropdownValueKey - * @property {Object} selectedValue - The selected value for the item - * @property {*} selectedValue.value - The value of the selected value - * @property {Object} inputRef - Reference to an input element - * @property {*} inputRef.value - The value of inputRef + * @property {{ value: Record }} attributes - Additional attributes for the item + * @property {{ value: boolean }} disabled - Whether the item is disabled + * @property {{ value: boolean }} active - Whether the item is active + * @property {{ value: boolean }} expand - Whether the item is expanded + * @property {{ value: ToolbarItem[] }} nestedOptions - Nested options for the item + * @property {{ value: Record | undefined }} style - Custom style for the item + * @property {{ value: boolean }} isNarrow - Whether the item has narrow styling + * @property {{ value: boolean }} isWide - Whether the item has wide styling + * @property {{ value: number | string | undefined }} minWidth - Minimum width of the item + * + * `argument.value` and `selectedValue.value` are intentionally + * `unknown` (not `any`): consumers pass arbitrary data through these + * to their custom command callbacks, so the toolbar cannot promise a + * narrower shape without becoming wrong. `unknown` forces the + * consumer to narrow at the call site they own. + * + * @property {{ value: unknown }} argument - The argument to pass to the command (consumer-typed) + * @property {{ value: ToolbarItem | undefined }} parentItem - The parent of this item if nested + * @property {{ value: ToolbarItem | undefined }} childItem - The child of this item if it has one + * @property {{ value: string | undefined }} iconColor - The color of the icon (CSS color) + * @property {{ value: boolean }} hasCaret - Whether the item has a dropdown caret + * @property {{ value: Record | undefined }} dropdownStyles - Custom styles for the dropdown + * @property {{ value: boolean }} tooltipVisible - Whether the tooltip is visible + * @property {{ value: number | undefined }} tooltipTimeout - Timeout for the tooltip (ms) + * @property {{ value: string | undefined }} defaultLabel - The default label for the item + * @property {{ value: string | undefined }} label - The label for the item + * @property {{ value: boolean }} hideLabel - Whether to hide the label + * @property {{ value: boolean }} inlineTextInputVisible - Whether inline text input is visible + * @property {{ value: boolean }} hasInlineTextInput - Whether the item has inline text input + * @property {{ value: string | undefined }} markName - The name of the mark (e.g. 'bold') + * @property {{ value: string | undefined }} labelAttr - The attribute for the label + * @property {{ value: boolean }} allowWithoutEditor - Whether the item can be used without an editor + * @property {{ value: string | undefined }} dropdownValueKey - The key for dropdown value + * @property {{ value: unknown }} selectedValue - The selected value (consumer-typed) + * @property {{ value: HTMLInputElement | null }} inputRef - Reference to an input element * @property {Function} unref - Function to get unreferenced values * @property {Function} activate - Function to activate the item * @property {Function} deactivate - Function to deactivate the item @@ -135,8 +116,8 @@ import { insertTableOfContentsAtSelection } from '@extensions/table-of-contents/ /** * @typedef {Object} CommandItem * @property {ToolbarItem} item - The toolbar item - * @property {*} [argument] - The argument to pass to the command - * @property {*} [option] - The selected nested option for option-style commands + * @property {unknown} [argument] - The argument to pass to the command (consumer-typed) + * @property {unknown} [option] - The selected nested option for option-style commands (consumer-typed) */ /** @@ -185,6 +166,68 @@ export class SuperToolbar extends EventEmitter { showFormattingMarksButton: false, }; + /** + * Visible toolbar items in their resolved order. Populated by + * `#initToolbarItems` after `useToolbarItem` builds the reactive + * wrappers; mutated when items move to overflow on resize. + * @type {ToolbarItem[]} + */ + toolbarItems = []; + + /** + * Items moved into the overflow menu when the container is narrower + * than the toolbar's natural width. + * @type {ToolbarItem[]} + */ + overflowItems = []; + + /** + * Dev mode flag forwarded from `SuperDoc`'s config. Enables extra + * dropdowns (e.g. extension picker) used only by internal tooling. + * @type {boolean} + */ + isDev = false; + + /** + * Role propagated from the parent `SuperDoc` (typically `'editor'` + * or `'viewer'`); drives feature gating in the toolbar items. + * @type {string} + */ + role = 'editor'; + + /** + * Back-reference to the owning `SuperDoc` instance. Marked private + * because it exposes the full SuperDoc internal graph and should + * not be part of the toolbar's public TypeScript surface. Internal + * paths that need a method on the parent SuperDoc reach for it + * through this field. + * @type {unknown} + * @private + */ + superdoc; + + /** + * Mounted toolbar container element, set after `render()`. Null + * before the first render or after `destroy()`. + * @type {HTMLElement | null} + */ + toolbarContainer = null; + + /** + * Mounted Vue component instance from `this.app.mount(...)`. Not + * consumer API: zero docs/examples reference `superdoc.toolbar.toolbar`, + * and no .ts or .js cross-file reader exists. The wrapper + * `SuperDoc.toolbar` (this class) is the documented public surface; + * this nested `.toolbar` field is the internal Vue mount handle. + * + * Same SD-3213f-style TS surface hide as `commentsList` and + * `SuperDoc.app`; not runtime privacy. + * + * @type {import('vue').ComponentPublicInstance | null} + * @private + */ + toolbar = null; + /** * Creates a new SuperToolbar instance * @param {ToolbarConfig} config - The configuration for the toolbar @@ -796,8 +839,9 @@ export class SuperToolbar extends EventEmitter { * Main handler for toolbar commands * @param {CommandItem} params - Command parameters * @param {ToolbarItem} params.item - An instance of the useToolbarItem composable - * @param {*} [params.argument] - The argument passed to the command - * @returns {*} The result of the executed command, undefined if no result is returned + * @param {unknown} [params.argument] - The argument passed to the command + * @returns {void} All control-flow branches use a bare `return;`; this method + * side-effects (emits events, mutates state) and produces no value. */ emitCommand({ item, argument, option }) { const hasFocusFn = this.activeEditor?.view?.hasFocus; @@ -999,7 +1043,7 @@ export class SuperToolbar extends EventEmitter { * @private * @param {Object} params * @param {string} params.command - * @param {*} params.argument + * @param {unknown} params.argument * @returns {void} */ #ensureStoredMarksForMarkToggle({ command, argument }) { 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/DocxZipper.d.ts b/packages/super-editor/src/editors/v1/core/DocxZipper.d.ts index 9359093860..f6ee35c470 100644 --- a/packages/super-editor/src/editors/v1/core/DocxZipper.d.ts +++ b/packages/super-editor/src/editors/v1/core/DocxZipper.d.ts @@ -1,4 +1,41 @@ +/** + * Hand-written declarations for `DocxZipper`. The implementation lives in + * the sibling `DocxZipper.js`. Earlier versions exposed + * `constructor(...args: any[])` + `[key: string]: any`, which collapsed + * every consumer access to `any`. SD-3213c replaced that catchall with an + * explicit minimal surface so DocxZipper no longer contributes to the + * audit's `tier-4-public-contract` bucket. + * + * Argument shapes are intentionally wide (`unknown`, `Record`) + * because the values internal callers pass are parsed OOXML JSON with no + * closed schema. Wide-but-not-any keeps `tsc` strict mode happy without + * pretending we have a type contract we cannot deliver. + */ export default class DocxZipper { - constructor(...args: any[]); - [key: string]: any; + constructor(params?: { debug?: boolean }); + + // Instance properties populated during read / export. Internal Editor + // code reads these directly. + media: Record; + mediaFiles: Record; + fonts: Record; + decryptedFileData: Uint8Array | null; + + // Instance methods called by internal Editor code. + getDocxData( + file: unknown, + isNode?: boolean, + options?: { password?: string }, + ): Promise<{ name: string; content: string }[]>; + updateContentTypes( + docx: unknown, + media: Record, + fromJson: boolean, + updatedDocs?: Record, + fonts?: Record, + ): Promise; + // Return type matches JSZip.generateAsync output as consumed by the + // internal export pipeline: Blob in the browser, Buffer in Node + // (headless mode). + updateZip(args: Record): Promise>; } diff --git a/packages/super-editor/src/editors/v1/core/Editor.exportDocx.types.spec.ts b/packages/super-editor/src/editors/v1/core/Editor.exportDocx.types.spec.ts index b2dda04db1..c5920f5e63 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.exportDocx.types.spec.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.exportDocx.types.spec.ts @@ -13,14 +13,19 @@ import { describe, it, expect } from 'vitest'; import type { Editor } from './Editor.js'; +import type { ConvertedXmlPart } from './types/EditorPublicSurfaces.js'; -// Never invoked โ€” pure type-level assertions. Wrapped in a function so vitest +// Never invoked. Pure type-level assertions. Wrapped in a function so vitest // doesn't try to execute the assignments at module load time. function _typeOnlyAssertions(editor: Editor): void { // Three narrow overloads. const _xmlOnly: Promise = editor.exportDocx({ exportXmlOnly: true }); - const _jsonOnly: Promise = editor.exportDocx({ exportJsonOnly: true }); + // SD-3248: exportJsonOnly returns the xml-js intermediate tree, NOT a + // JSON string. The original `Promise` overload was a type lie; + // runtime always returned an object with a recursive `name` / + // `attributes` / `elements` shape. + const _jsonOnly: Promise = editor.exportDocx({ exportJsonOnly: true }); const _updatedDocs: Promise> = editor.exportDocx({ getUpdatedDocs: true }); // Default overload: T defaults to Blob, so browser consumers get diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index e4096534de..edb00414a0 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -18,6 +18,11 @@ 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 { + ConvertedXmlPart, + EditorConverterSurface, + EditorExtensionServiceSurface, +} from './types/EditorPublicSurfaces.js'; import { Commands, Editable, @@ -253,10 +258,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 @@ -265,9 +268,17 @@ export class Editor extends EventEmitter { /** * ProseMirror schema for the editor. + * + * Typed as `Schema` rather than bare `Schema` to + * drop the implicit `Schema` default through the SD-3213 + * supported-root audit. Node and mark name spaces are typed as + * `string` (the established ProseMirror constraint shape); consumer + * schemas with literal-typed names like `Schema<'paragraph', 'em'>` + * remain assignable. + * * @deprecated Direct ProseMirror access will be removed in a future version. Use the Document API (`editor.doc`) instead. */ - schema!: Schema; + schema!: Schema; /** * ProseMirror view instance. @@ -340,7 +351,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) @@ -640,7 +652,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 { @@ -1100,7 +1112,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; } @@ -1947,10 +1966,22 @@ export class Editor extends EventEmitter { /** * Register PM plugin. + * + * `PluginState` is a call-site generic so the incoming plugin's + * state shape is preserved into the `handlePlugins` callback's + * `plugin` argument. The existing plugin list is heterogeneous + * (each entry can have a different state type) so it stays as + * `Plugin[]` instead of being inferred under one specific + * state. Default `PluginState = unknown` removes the previous + * implicit `Plugin` SD-3213 supported-root finding. + * * @param plugin PM plugin. * @param handlePlugins Optional function for handling plugin merge. */ - registerPlugin(plugin: Plugin, handlePlugins?: (plugin: Plugin, plugins: Plugin[]) => Plugin[]): void { + registerPlugin( + plugin: Plugin, + handlePlugins?: (plugin: Plugin, plugins: Plugin[]) => Plugin[], + ): void { if (this.isDestroyed) return; if (!this.state?.plugins) return; const plugins = @@ -2095,7 +2126,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; } /** @@ -2111,8 +2150,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, @@ -2125,7 +2168,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; } } @@ -2347,7 +2390,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); @@ -3154,16 +3200,20 @@ export class Editor extends EventEmitter { * * Return type depends on flags: * - `exportXmlOnly: true` โ†’ `string` (raw XML) - * - `exportJsonOnly: true` โ†’ `string` (JSON string) + * - `exportJsonOnly: true` โ†’ `ConvertedXmlPart` (the xml-js intermediate + * tree; recursive `name` / `attributes` / `elements` shape, NOT a JSON + * string). SD-3248: this overload previously typed as `Promise`, + * which did not match runtime. Consumers should walk the returned tree + * directly; calling `JSON.parse` on it would never have worked. * - `getUpdatedDocs: true` โ†’ `Record` (file map) * - Default โ†’ `Blob` (browser) or `Buffer` (Node.js headless). The runtime * value is determined by the editor's `isHeadless` option at construction - * time, which the type system cannot see โ€” so the default overload is + * time, which the type system cannot see, so the default overload is * generic with `Blob` as the default. Browser consumers get `Blob` * automatically; Node headless consumers opt in with `exportDocx()`. */ async exportDocx(params: ExportDocxParams & { exportXmlOnly: true }): Promise; - async exportDocx(params: ExportDocxParams & { exportJsonOnly: true }): Promise; + async exportDocx(params: ExportDocxParams & { exportJsonOnly: true }): Promise; async exportDocx(params: ExportDocxParams & { getUpdatedDocs: true }): Promise>; async exportDocx( params?: ExportDocxParams & { exportXmlOnly?: false; exportJsonOnly?: false; getUpdatedDocs?: false }, @@ -3177,7 +3227,9 @@ export class Editor extends EventEmitter { getUpdatedDocs = false, fieldsHighlightColor = null, compression, - }: ExportDocxParams = {}): Promise | string | undefined> { + }: ExportDocxParams = {}): Promise< + Blob | Buffer | Record | string | ConvertedXmlPart | undefined + > { try { const exportHostEditor = resolveMainBodyEditor(this); commitLiveStorySessionRuntimes(exportHostEditor); diff --git a/packages/super-editor/src/editors/v1/core/EventEmitter.ts b/packages/super-editor/src/editors/v1/core/EventEmitter.ts index c4b1c8966e..63e1b630de 100644 --- a/packages/super-editor/src/editors/v1/core/EventEmitter.ts +++ b/packages/super-editor/src/editors/v1/core/EventEmitter.ts @@ -1,17 +1,25 @@ /** - * Default event map with string keys and any arguments. - * Using `any[]` is necessary here to allow flexible event argument types - * while maintaining type safety through generic constraints in EventEmitter. + * Default event map: string event names โ†’ tuple of payload args. + * + * The index-signature value is `unknown[]` (SD-3213 EventEmitter drain). + * Specific event maps that extend this still type their known events + * precisely (see `EditorEventMap`); the index-signature fallback only + * governs untyped event names like `editor.on('arbitraryEvent', cb)`, + * where consumers now get `cb: (...args: unknown[]) => void` instead + * of `any[]`. That keeps unsafe IntelliSense collapse out of the + * public surface while leaving typed events untouched. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DefaultEventMap = Record; +export type DefaultEventMap = Record; /** * Event callback function type. - * Using `any[]` default is necessary for variance and compatibility with event handlers. + * + * Default `Args extends unknown[] = unknown[]` (was `any[]`, SD-3213). + * Variance: when a specific event map provides a tighter tuple via + * `EventMap[K]`, that flows through to `EventCallback` at + * the call site, so typed events keep their precise payloads. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type EventCallback = (...args: Args) => void; +export type EventCallback = (...args: Args) => void; /** * EventEmitter class is used to emit and subscribe to events. diff --git a/packages/super-editor/src/editors/v1/core/Node.ts b/packages/super-editor/src/editors/v1/core/Node.ts index 00fa3f248f..4e5fd164e9 100644 --- a/packages/super-editor/src/editors/v1/core/Node.ts +++ b/packages/super-editor/src/editors/v1/core/Node.ts @@ -1,13 +1,71 @@ import { getExtensionConfigField } from './helpers/getExtensionConfigField.js'; import { callOrGet } from './utilities/callOrGet.js'; import type { MaybeGetter } from './utilities/callOrGet.js'; -import type { NodeType, ParseRule, DOMOutputSpec, Node as PmNode } from 'prosemirror-model'; +import type { NodeType, ParseRule, Node as PmNode } from 'prosemirror-model'; import type { Plugin } from 'prosemirror-state'; import type { NodeView, EditorView, Decoration, DecorationSource } from 'prosemirror-view'; import type { InputRule } from './InputRule.js'; import type { Editor } from './Editor.js'; import type { Command } from './types/ChainedCommands.js'; -import type { AttributeSpec } from './Attribute.js'; +import type { AttributeSpec, AttributeValue } from './Attribute.js'; + +/** + * Attribute object accepted inside a `SuperDocDOMOutputSpec` tuple. + * Runtime `setAttribute` coerces non-string values, so number / + * boolean / null / undefined are accepted alongside string. + */ +export type RenderDOMAttrs = Record; + +/** + * Tuple branch of `SuperDocDOMOutputSpec`, declared as an interface + * to break TypeScript's direct-recursion check (PM's upstream + * `readonly [string, ...any[]]` avoids this because `any` swallows + * the recursion; we cannot replicate that with `unknown`). The + * interface form defers the self-reference through a named type, + * which TS accepts. + * + * The shape mirrors PM's tuple convention: index 0 is the tagName, + * subsequent items are an optional attrs object, the literal `0` + * (content hole), or nested specs. Both readonly and mutable + * arrays satisfy this interface because `ReadonlyArray` is the + * supertype of `Array`. + */ +export interface SuperDocDOMOutputSpecTuple extends ReadonlyArray { + readonly 0: string; +} + +/** + * Public DOM rendering output spec for `NodeConfig.renderDOM`. + * Mirrors ProseMirror's `DOMOutputSpec` shape but replaces the + * upstream `readonly [string, ...any[]]` tuple branch with the + * `SuperDocDOMOutputSpecTuple` interface above, so the public type + * surface does not leak `any` through the SD-3213 supported-root + * audit. + * + * `globalThis.Node` is used to disambiguate from the editor `Node` + * class exported from this same file. + */ +export type SuperDocDOMOutputSpec = + | string + | globalThis.Node + | { dom: globalThis.Node; contentDOM?: HTMLElement } + | SuperDocDOMOutputSpecTuple; + +/** + * Public function signature for `NodeConfig.renderDOM`. Function-only + * (not `MaybeGetter`) because the runtime in + * `packages/super-editor/src/editors/v1/core/Schema.js:99` invokes + * `renderDOM({ node, htmlAttributes })` directly with no + * `callOrGet()` wrapper. Typing it as `MaybeGetter` would + * advertise a direct-value form that throws `TypeError` at runtime. + * + * `htmlAttributes` is the `Record` that + * `Attribute.getAttributesToRender()` returns at runtime. + */ +export type RenderDOMFn = (props: { + node: PmNode; + htmlAttributes: Record; +}) => SuperDocDOMOutputSpec; /** * Configuration for Node extensions. @@ -68,8 +126,21 @@ export interface NodeConfig< /** The DOM parsing rules */ parseDOM?: MaybeGetter; - /** The DOM rendering function - returns a DOMOutputSpec (allows mutable arrays for JS compatibility) */ - renderDOM?: MaybeGetter; + /** + * The DOM rendering function for the node. Receives + * `{ node, htmlAttributes }` at runtime (see `Schema.js:99`) and + * returns a `SuperDocDOMOutputSpec`: a public local alias + * mirroring ProseMirror's `DOMOutputSpec` shape but with an + * unknown-free tuple branch. + * + * Typed as a plain function (`RenderDOMFn`) rather than + * `MaybeGetter` because the runtime only + * invokes the function form. A direct-value `renderDOM: ['br']` + * type-checks under `MaybeGetter` but throws `TypeError` at + * runtime. Narrowing the type aligns the public contract with + * what the runtime actually supports. + */ + renderDOM?: RenderDOMFn; /** Function or object to add options to the node */ addOptions?: MaybeGetter; @@ -107,7 +178,7 @@ export interface NodeConfig< >; /** Function to add ProseMirror plugins to the node */ - addPmPlugins?: MaybeGetter; + addPmPlugins?: MaybeGetter[]>; /** Function to extend the ProseMirror node schema */ extendNodeSchema?: MaybeGetter>; 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/helpers/paragraph-alignment.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js index 3a10964fd4..29fcf8436b 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js @@ -1,6 +1,7 @@ /** - * Maps display alignment (UI-facing physical left/right/center/justify) to - * stored OOXML paragraph justification, honoring RTL paragraph direction. + * Maps visual alignment (UI-facing physical left/right/center/justify) to the + * stored OOXML paragraph justification value Microsoft Word expects for the + * paragraph direction. * * @param {'left' | 'center' | 'right' | 'justify'} alignment * @param {boolean} isRtl @@ -15,8 +16,8 @@ export function mapDisplayAlignmentToStoredJustification(alignment, isRtl) { } /** - * Maps stored OOXML paragraph justification to display alignment, honoring - * RTL paragraph direction. When justification is absent, returns the + * Maps stored OOXML paragraph justification to visual alignment, honoring + * Word's RTL interpretation. When justification is absent, returns the * visual default by direction. * * @param {string | null | undefined} justification diff --git a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js index 77c1fee0bb..9d2d188d14 100644 --- a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js @@ -4,11 +4,11 @@ import { mapStoredJustificationToDisplayAlignment, } from './paragraph-alignment.js'; -// SD-3094: per ECMA-376 ยง17.3.1.13, `w:jc="left"` is the leading edge of the -// paragraph, not the physical left. In RTL paragraphs (`w:bidi`), leading is -// the right side. The two helpers below own the display โ†” stored translation -// so the editor can keep its UI in physical terms while the model stays in the -// spec's logical terms. +// SD-3094: Microsoft Word interprets `w:jc` through paragraph direction: +// in an RTL paragraph (`w:bidi`), stored `left` renders on the right side and +// stored `right` renders on the left side. The helpers below own the visual โ†” +// stored translation so API/UI callers can request visual page alignment +// while exported DOCX keeps Word-compatible stored values. describe('mapDisplayAlignmentToStoredJustification', () => { describe('LTR paragraphs (isRtl=false)', () => { 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/presentation-editor/dom/EntityRectFinder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts index 1f06352247..28072b83b2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts @@ -241,8 +241,7 @@ function paintSdtWrapper( // in the painter tests: see painters/dom/src/index.test.ts which // hardcodes these strings. Prefer the constant here.) wrapper.className = - opts.className ?? - (opts.scope === 'block' ? DOM_CLASS_NAMES.BLOCK_SDT : DOM_CLASS_NAMES.INLINE_SDT_WRAPPER); + opts.className ?? (opts.scope === 'block' ? DOM_CLASS_NAMES.BLOCK_SDT : DOM_CLASS_NAMES.INLINE_SDT_WRAPPER); } wrapper.dataset.sdtId = id; wrapper.dataset.sdtType = opts.type ?? 'structuredContent'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts index 773e56f5a8..1fa76a804f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts @@ -1,8 +1,52 @@ +/** + * Hand-written declarations for `SuperConverter`. The implementation lives + * in the sibling `SuperConverter.js`. + * + * SD-3213c partially drained this shim: + * - typed constructor (no more `constructor(...args: any[])`) + * - typed the named static methods and exported helper function + * - tightened `extractDocumentGuid` / `getStoredSuperdocVersion` / + * `setStoredSuperdocVersion` from `any[]` to specific shapes + * + * The `[key: string]: any` catchall is intentionally retained for now. + * `SuperConverter.d.ts` doubles as the type source for a large `.js` + * implementation; internal callers (`Editor.ts`, `PresentationEditor.ts`, + * `HeaderFooterRegistry.ts`, list-level helpers, etc.) read dozens of + * instance properties and methods on this class via the index signature. + * Tightening the public shape without converting the impl to TypeScript + * (or splitting public/internal contracts) cascades into ~60 typecheck + * errors across the repo. Tracked as follow-up: convert SuperConverter + * to TS or formalize a public/internal contract split. + * + * Consumer note: external code accessing `SuperConverter` instance + * properties or methods through the index signature still resolves to + * `any`. This is debt, not desired public API. Anything you read off a + * SuperConverter instance today is not part of the stable contract. + */ export class SuperConverter { - constructor(...args: any[]); - static getStoredSuperdocVersion(...args: any[]): any; - static setStoredSuperdocVersion(...args: any[]): void; - static extractDocumentGuid(...args: any[]): string | null; + constructor(params?: { + debug?: boolean; + mockWindow?: unknown; + mockDocument?: unknown; + docx?: unknown; + media?: Record; + fonts?: Record; + xml?: string; + json?: unknown; + fileSource?: unknown; + documentId?: string | null; + isNewFile?: boolean; + trackedChangesOptions?: { replacements?: 'paired' | 'independent' } | null; + }); + + static getStoredSuperdocVersion(docx: readonly { readonly name: string; readonly content: string }[]): string | null; + // The setter accepts either shape (array of file entries or mutable map + // keyed by package path); the underlying `setStoredCustomProperty` does + // `docx[customLocation] = ...`, which works on both at runtime. + static setStoredSuperdocVersion(docx: unknown, version?: string): string | null; + static extractDocumentGuid(docx: readonly { readonly name: string; readonly content: string }[]): string | null; + + // Internal-implementation catchall. See file header for context. [key: string]: any; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js b/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js index 45c461a887..2a5f135503 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js @@ -16,10 +16,8 @@ export const CUSTOM_XML_DATA_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml'; export const CUSTOM_XML_PROPS_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps'; -export const CUSTOM_XML_PROPS_CONTENT_TYPE = - 'application/vnd.openxmlformats-officedocument.customXmlProperties+xml'; -export const CUSTOM_XML_DATASTORE_NAMESPACE = - 'http://schemas.openxmlformats.org/officeDocument/2006/customXml'; +export const CUSTOM_XML_PROPS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.customXmlProperties+xml'; +export const CUSTOM_XML_DATASTORE_NAMESPACE = 'http://schemas.openxmlformats.org/officeDocument/2006/customXml'; // --------------------------------------------------------------------------- // Local helpers @@ -367,19 +365,20 @@ function buildItemPropsRoot(itemId, schemaRefs) { 'ds:itemID': itemId, 'xmlns:ds': CUSTOM_XML_DATASTORE_NAMESPACE, }, - elements: schemaRefs === undefined - ? [] - : [ - { - type: 'element', - name: 'ds:schemaRefs', - elements: schemaRefs.map((uri) => ({ + elements: + schemaRefs === undefined + ? [] + : [ + { type: 'element', - name: 'ds:schemaRef', - attributes: { 'ds:uri': uri }, - })), - }, - ], + name: 'ds:schemaRefs', + elements: schemaRefs.map((uri) => ({ + type: 'element', + name: 'ds:schemaRef', + attributes: { 'ds:uri': uri }, + })), + }, + ], }, ]; return elements[0]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/zipper.js b/packages/super-editor/src/editors/v1/core/super-converter/zipper.js index f666d01ae6..246b8c527f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/zipper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/zipper.js @@ -1,9 +1,16 @@ import JSZip from 'jszip'; /** - * Take a list of blobs and file names and create a zip file - * @param {Array[Blob]} blobs List of blobs to zip - * @param {Array[string]} fileNames List of file names to zip + * Take a list of blobs and file names and create a zip file. + * + * The previous `@param {Array[Blob]}` / `@param {Array[string]}` + * syntax was invalid JSDoc (the array type expression is `Type[]` + * or `Array`, not `Array[Type]`). TypeScript parsed the + * malformed syntax and fell back to `any`, leaking through the + * SD-3213 supported-root audit. + * + * @param {Blob[]} blobs List of blobs to zip + * @param {string[]} fileNames List of file names to zip * @returns {Promise} The zipped file */ export async function createZip(blobs, fileNames) { diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index ea2e34dc66..fff7822b16 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -209,11 +209,18 @@ export interface Awareness { /** * Collaboration provider interface. * Accepts any Yjs-compatible provider (HocuspocusProvider, LiveblocksYjsProvider, TiptapCollabProvider, etc.) + * + * `on`/`off` use `(event: string, handler: (...args: unknown[]) => void)` + * to match the established pattern on `Awareness` above and the internal + * `ProviderEventHandler` cast in `helpers/collaboration-provider-sync.ts`. + * Consumers narrow `args` before reading; this is a TS-only tightening + * (no runtime change) that drains 32 SD-3213 supported-root any[] + * findings on EditorConfig.d.ts in a single source edit. */ export interface CollaborationProvider { awareness?: Awareness | null; - on?(event: any, handler: (...args: any[]) => void): void; - off?(event: any, handler: (...args: any[]) => void): void; + on?(event: string, handler: (...args: unknown[]) => void): void; + off?(event: string, handler: (...args: unknown[]) => void): void; disconnect?(): void; destroy?(): void; /** Whether provider is synced - some use `synced`, others `isSynced` */ 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..75b326981f --- /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 + * `ConvertedXmlPart` (xml-js 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/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index f435142c8f..144c24ec97 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -8,6 +8,7 @@ import { getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + syncCommentEntitiesFromCollaboration, toCommentInfo, upsertCommentEntity, type CommentEntityRecord, @@ -252,3 +253,352 @@ describe('toCommentInfo', () => { expect(info.anchoredText).toBeUndefined(); }); }); + +describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { + it('upserts a new browser-authored comment into an empty store', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'c1', + commentText: 'Please review this clause.', + creatorName: 'Browser User', + creatorEmail: 'browser@example.com', + createdTime: 1700000000000, + isInternal: false, + }, + ]); + + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('c1'); + expect(store[0].commentText).toBe('Please review this clause.'); + expect(store[0].creatorName).toBe('Browser User'); + expect(store[0].creatorEmail).toBe('browser@example.com'); + expect(store[0].createdTime).toBe(1700000000000); + expect(store[0].isInternal).toBe(false); + }); + + it('accepts `text` as a fallback for `commentText`', () => { + // Some browser writers emit { text } instead of { commentText }; mirror + // the alias logic the browser-side loader uses. + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'c1', text: 'short form' }]); + const store = getCommentEntityStore(editor); + expect(store[0].commentText).toBe('short form'); + }); + + it('skips entries flagged trackedChange:true (those belong to a separate domain)', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'tc-1', trackedChange: true, trackedChangeText: 'inserted', creatorName: 'A' }, + { commentId: 'c-1', commentText: 'real comment', creatorName: 'B' }, + ]); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('c-1'); + }); + + it('skips entries without a commentId', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [{ creatorName: 'orphan' }, { commentId: 'c-ok', creatorName: 'X' }]); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('c-ok'); + }); + + it('falls back to importedId when commentId is missing', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [{ importedId: 'imp-1', creatorName: 'X' }]); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('imp-1'); + expect(store[0].importedId).toBe('imp-1'); + }); + + it('merges an updated entry without clobbering unchanged fields', () => { + const editor = makeEditorWithConverter([ + { + commentId: 'c1', + commentText: 'v1', + creatorName: 'Author', + creatorEmail: 'author@example.com', + createdTime: 1, + }, + ]); + // Remote update bumps commentText only. + syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'c1', commentText: 'v2' }]); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentText).toBe('v2'); + expect(store[0].creatorName).toBe('Author'); + expect(store[0].creatorEmail).toBe('author@example.com'); + expect(store[0].createdTime).toBe(1); + }); + + it('propagates resolution metadata', () => { + const editor = makeEditorWithConverter([{ commentId: 'c1', commentText: 'hi' }]); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'c1', + commentText: 'hi', + isDone: true, + resolvedTime: 1700000005000, + resolvedByEmail: 'resolver@example.com', + resolvedByName: 'Resolver', + }, + ]); + const store = getCommentEntityStore(editor); + expect(store[0].isDone).toBe(true); + expect(store[0].resolvedTime).toBe(1700000005000); + expect(store[0].resolvedByEmail).toBe('resolver@example.com'); + expect(store[0].resolvedByName).toBe('Resolver'); + }); + + it('returns the set of synced comment ids (for caller-driven deletion sweep)', () => { + const editor = makeEditorWithConverter(); + const seen = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'c1' }, + { commentId: 'c2' }, + { trackedChange: true, commentId: 'tc-1' }, + ]); + expect(seen).toEqual(new Set(['c1', 'c2'])); + }); + + it('is a no-op for empty input', () => { + const editor = makeEditorWithConverter([{ commentId: 'pre', commentText: 'kept' }]); + syncCommentEntitiesFromCollaboration(editor, []); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentText).toBe('kept'); + }); + + // Remote-deletion handling: when a prior collab-synced id disappears from + // the upstream Y.Array, the helper prunes the matching store entry. + it('prunes a previously-synced entry that is no longer in upstream entries', () => { + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'a', commentText: 'a' }, + { commentId: 'b', commentText: 'b' }, + ]); + expect(getCommentEntityStore(editor)).toHaveLength(2); + expect(first).toEqual(new Set(['a', 'b'])); + + // Remote drops 'a'. + const second = syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'b', commentText: 'b' }], { + previouslySynced: first, + }); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('b'); + expect(second).toEqual(new Set(['b'])); + }); + + it('does not prune locally-authored entries that were never collab-synced', () => { + // 'local' is in the store but never in `previouslySynced` โ€” the helper + // must leave it alone even though it isn't in the upstream entries. + const editor = makeEditorWithConverter([{ commentId: 'local', commentText: 'cli-authored' }]); + syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'remote', commentText: 'r' }], { + previouslySynced: new Set(), + }); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(2); + expect(store.map((e) => e.commentId).sort()).toEqual(['local', 'remote']); + }); + + it('prunes thread replies along with the deleted parent', () => { + // removeCommentEntityTree cascades to children โ€” confirm via the helper. + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'root' }, + { commentId: 'reply-1', parentCommentId: 'root' }, + { commentId: 'reply-2', parentCommentId: 'root' }, + { commentId: 'unrelated' }, + ]); + expect(getCommentEntityStore(editor)).toHaveLength(4); + + syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'unrelated' }], { + previouslySynced: first, + }); + const store = getCommentEntityStore(editor); + expect(store.map((e) => e.commentId).sort()).toEqual(['unrelated']); + }); + + it('returns the new sync set unchanged when no removals occur', () => { + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'a' }, { commentId: 'b' }]); + const second = syncCommentEntitiesFromCollaboration( + editor, + [{ commentId: 'a' }, { commentId: 'b' }, { commentId: 'c' }], + { previouslySynced: first }, + ); + expect(second).toEqual(new Set(['a', 'b', 'c'])); + expect(getCommentEntityStore(editor)).toHaveLength(3); + }); + + // Codex P2 โ€” "Keep deleted thread descendants out of the sync set": + // packages/superdoc/.../collaboration-comments.js#deleteYComment removes + // only the parent index from Y.Array. The browser UI drops replies + // locally. If our helper iterates the upstream array AFTER the browser + // delete, it would still see the reply entries and upsert them. Even + // though removeCommentEntityTree cascades the parent's deletion through + // the store, the returned `seen` set would still contain the reply id + // because the reply was upserted in that same pass โ€” so the next sync + // would re-upsert the reply as an orphan with no parent. + describe('orphaned-reply handling (Codex P2)', () => { + it('does not upsert a reply whose parent disappeared from the upstream array', () => { + const editor = makeEditorWithConverter(); + // Initial sync โ€” parent and reply both upstream. + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'root', commentText: 'parent body' }, + { commentId: 'reply-1', parentCommentId: 'root', commentText: 'reply body' }, + ]); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + ).toEqual(['reply-1', 'root']); + + // Browser deletes parent only; reply still in upstream. + const second = syncCommentEntitiesFromCollaboration( + editor, + [{ commentId: 'reply-1', parentCommentId: 'root', commentText: 'reply body' }], + { previouslySynced: first }, + ); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + 'parent + reply must both be pruned after parent deletion', + ).toEqual([]); + // And the returned sync set must NOT include the orphan reply, otherwise + // the next observer fire would re-upsert it as a parent-less orphan. + expect(second.has('reply-1'), 'reply id must not survive in the next sync set').toBe(false); + }); + + it('does not resurrect a reply as an orphan on a subsequent sync over the same upstream', () => { + // Same scenario as above, but we now run THREE syncs back-to-back, all + // observing the same "parent missing, reply present" upstream. Without + // the fix, the second and third syncs would re-upsert the reply each + // time, leaving an orphan record in the store. + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'root' }, + { commentId: 'reply-1', parentCommentId: 'root' }, + ]); + + const second = syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'reply-1', parentCommentId: 'root' }], { + previouslySynced: first, + }); + + const third = syncCommentEntitiesFromCollaboration(editor, [{ commentId: 'reply-1', parentCommentId: 'root' }], { + previouslySynced: second, + }); + + expect(getCommentEntityStore(editor).map((e) => e.commentId)).toEqual([]); + expect(third.has('reply-1'), 'orphan reply must not appear in the third sync set').toBe(false); + }); + + it('still upserts a reply when its parent is in the same upstream pass (preserves valid threads)', () => { + // Sanity check that the orphan filter does NOT break the common case: + // parent + reply both upstream โ†’ both upserted. + const editor = makeEditorWithConverter(); + const seen = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'root' }, + { commentId: 'reply-1', parentCommentId: 'root' }, + { commentId: 'reply-2', parentCommentId: 'root' }, + ]); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + ).toEqual(['reply-1', 'reply-2', 'root']); + expect(seen).toEqual(new Set(['root', 'reply-1', 'reply-2'])); + }); + + it('treats importedId as a valid parent reference (legacy DOCX threads)', () => { + // Imported comments may carry only `importedId`; a reply's + // `parentCommentId` can point at the parent's importedId rather than + // its canonical commentId. + const editor = makeEditorWithConverter(); + const seen = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'canonical-root', importedId: '0' }, + { commentId: 'reply-1', parentCommentId: '0' }, + ]); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + ).toEqual(['canonical-root', 'reply-1']); + expect(seen.has('reply-1'), 'reply pointing at parent.importedId must still be accepted').toBe(true); + }); + + // Codex follow-up: a one-shot orphan filter (build upstreamIds once, + // skip entries whose direct parent is missing) handles Aโ†’B but breaks on + // Aโ†’Bโ†’C. B is correctly skipped because A is gone, but C's parent B is + // still present in `upstreamIds`, so C survives the upsert and dangles + // as an orphan whose chain leads nowhere. Filter must be applied + // transitively until the upstream set is stable. + it('drops the entire orphan chain when an ancestor is missing upstream (Aโ†’Bโ†’C, A deleted)', () => { + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'A' }, + { commentId: 'B', parentCommentId: 'A' }, + { commentId: 'C', parentCommentId: 'B' }, + ]); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + ).toEqual(['A', 'B', 'C']); + + // A is deleted upstream. B and C linger (browser only flushed A). + const second = syncCommentEntitiesFromCollaboration( + editor, + [ + { commentId: 'B', parentCommentId: 'A' }, + { commentId: 'C', parentCommentId: 'B' }, + ], + { previouslySynced: first }, + ); + expect( + getCommentEntityStore(editor) + .map((e) => e.commentId) + .sort(), + 'A โ†’ B โ†’ C: with A gone, B and C must both be pruned', + ).toEqual([]); + expect(second.has('B'), 'B must not appear in the sync set').toBe(false); + expect(second.has('C'), 'C must not appear in the sync set (or it would be re-upserted)').toBe(false); + }); + + it('does not resurrect a grandchild orphan on subsequent syncs over the same upstream (Aโ†’Bโ†’C)', () => { + const editor = makeEditorWithConverter(); + const first = syncCommentEntitiesFromCollaboration(editor, [ + { commentId: 'A' }, + { commentId: 'B', parentCommentId: 'A' }, + { commentId: 'C', parentCommentId: 'B' }, + ]); + + const second = syncCommentEntitiesFromCollaboration( + editor, + [ + { commentId: 'B', parentCommentId: 'A' }, + { commentId: 'C', parentCommentId: 'B' }, + ], + { previouslySynced: first }, + ); + + const third = syncCommentEntitiesFromCollaboration( + editor, + [ + { commentId: 'B', parentCommentId: 'A' }, + { commentId: 'C', parentCommentId: 'B' }, + ], + { previouslySynced: second }, + ); + + expect(getCommentEntityStore(editor).map((e) => e.commentId)).toEqual([]); + expect(third.has('B')).toBe(false); + expect(third.has('C')).toBe(false); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index c1f8712413..cd6d1fdfdf 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -182,6 +182,137 @@ export function isCommentResolved(entry: CommentEntityRecord): boolean { return Boolean(entry.isDone || entry.resolvedTime); } +/** + * Sync remote comment metadata from a collaboration channel (e.g. the Yjs + * `ydoc.getArray('comments')` used by browser SuperDoc clients) into the + * editor's CommentEntityStore. + * + * Without this sync, the headless SDK only sees PM-anchor-derived fields + * (id, target, anchoredText, status) for browser-authored comments โ€” the + * Y.Array metadata (text, creatorName, creatorEmail, createdTime) never + * reaches `doc.comments.list()`. See SD-3214. + * + * Behavior: + * - Each entry with a `commentId` is upserted into the store. Existing + * entries are merged (collaborator-authored fields override locally + * captured ones; missing fields are left alone). + * - Entries flagged `trackedChange: true` are skipped โ€” those belong to + * the tracked-changes domain, not the comments store. + * - When `options.previouslySynced` is provided, any id present in the + * prior set but absent from the current entries is treated as a remote + * deletion and pruned via `removeCommentEntityTree`. Locally-authored + * entries that were never collab-synced are left alone. + * - Returns the set of commentIds observed during the sync. Callers should + * pass this back as `previouslySynced` on the next call to detect + * subsequent remote deletions. + */ +export function syncCommentEntitiesFromCollaboration( + editor: Editor, + entries: ReadonlyArray>, + options: { previouslySynced?: ReadonlySet } = {}, +): Set { + const store = getCommentEntityStore(editor); + const seen = new Set(); + + // Pre-pass: collect every id (commentId AND importedId) that exists in the + // current upstream array, then transitively drop entries whose + // `parentCommentId` is missing from the set. `deleteYComment` on the + // browser side removes only the parent index from Y.Array โ€” replies (and + // replies-of-replies) linger upstream until the browser flushes them. A + // single-pass filter handles Aโ†’B (B skipped when A is gone) but breaks on + // Aโ†’Bโ†’C: B would be skipped, yet B's id is still in `upstreamIds`, so C + // survives and dangles as an orphan whose chain leads nowhere. The + // fixed-point loop below removes orphan ids from the set until stable, so + // any depth of orphan chain collapses in one go. + const upstreamIds = new Set(); + const validEntries: Array> = []; + for (const raw of entries) { + if (!raw || typeof raw !== 'object') continue; + if (raw.trackedChange === true) continue; + const cid = toNonEmptyString(raw.commentId); + const iid = toNonEmptyString(raw.importedId); + if (cid) upstreamIds.add(cid); + if (iid) upstreamIds.add(iid); + validEntries.push(raw); + } + + // Iteratively drop orphan ids until the set is stable. Each pass removes + // entries whose declared parent is no longer represented in `upstreamIds`; + // the next pass then re-evaluates entries that were transitively orphaned + // by the previous removal. Worst-case cost is O(depth ร— validEntries), + // bounded by document size โ€” Y.Array of comments is small in practice. + let changed = true; + while (changed) { + changed = false; + for (const raw of validEntries) { + const parentRef = toNonEmptyString(raw.parentCommentId); + if (!parentRef) continue; + if (upstreamIds.has(parentRef)) continue; + const cid = toNonEmptyString(raw.commentId); + const iid = toNonEmptyString(raw.importedId); + if (cid && upstreamIds.delete(cid)) changed = true; + if (iid && upstreamIds.delete(iid)) changed = true; + } + } + + for (const raw of validEntries) { + const commentId = toNonEmptyString(raw.commentId) ?? toNonEmptyString(raw.importedId); + if (!commentId) continue; + + // After the fixed-point pass, an entry is an orphan iff its own id was + // dropped from `upstreamIds`. Skip it so the prune step can cascade- + // delete the local record without `seen` re-marking the orphan as live. + if (!upstreamIds.has(commentId)) continue; + + seen.add(commentId); + + const patch: Partial = {}; + // Identity fields + if (typeof raw.importedId === 'string') patch.importedId = raw.importedId; + if (typeof raw.parentCommentId === 'string') patch.parentCommentId = raw.parentCommentId; + // Body + const commentText = + typeof raw.commentText === 'string' ? raw.commentText : typeof raw.text === 'string' ? raw.text : undefined; + if (commentText !== undefined) patch.commentText = commentText; + if (raw.commentJSON !== undefined) patch.commentJSON = raw.commentJSON; + if (raw.elements !== undefined) patch.elements = raw.elements; + // Authoring metadata + if (typeof raw.creatorName === 'string') patch.creatorName = raw.creatorName; + if (typeof raw.creatorEmail === 'string') patch.creatorEmail = raw.creatorEmail; + if (typeof raw.creatorImage === 'string') patch.creatorImage = raw.creatorImage; + if (typeof raw.createdTime === 'number') patch.createdTime = raw.createdTime; + // Status + if (typeof raw.isInternal === 'boolean') patch.isInternal = raw.isInternal; + if (typeof raw.isDone === 'boolean') patch.isDone = raw.isDone; + if (typeof raw.resolvedTime === 'number') patch.resolvedTime = raw.resolvedTime; + if (raw.resolvedTime === null) patch.resolvedTime = null; + if (typeof raw.resolvedByEmail === 'string') patch.resolvedByEmail = raw.resolvedByEmail; + if (typeof raw.resolvedByName === 'string') patch.resolvedByName = raw.resolvedByName; + + upsertCommentEntity(store, commentId, patch); + } + + // Prune entries previously known to come from collab sync but now absent + // from the upstream Y.Array. Locally-authored entries that were never in + // `previouslySynced` are intentionally left alone. + if (options.previouslySynced) { + for (const priorId of options.previouslySynced) { + if (!seen.has(priorId)) { + removeCommentEntityTree(store, priorId); + } + } + } + + return seen; +} + +/** Local helper: trim+narrow a value to a non-empty string. */ +function toNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export function toCommentInfo( entry: CommentEntityRecord, options: { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts index 97d638494f..1c529528df 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts @@ -332,6 +332,7 @@ describe('anchored metadata wrappers', () => { }); const doc = createNode('doc', [paragraph], { isBlock: false }); const editor = makeEditor(doc); + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [{ id: 'meta-1', json: '{"label":"Alpha"}' }]); expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toEqual({ id: 'meta-1', @@ -342,4 +343,60 @@ describe('anchored metadata wrappers', () => { }, }); }); + + it('returns null when the SDT tag has no matching payload entry (foreign content control with same w:tag)', () => { + // Imported DOCX with an inline SDT whose `w:tag === 'meta-1'` but no + // customXml payload entry โ€” could be a Word-authored content control + // that happens to share an id with what a consumer would `attach`. + // Both halves of the anchor must agree before `resolve` reports the + // id resolves, otherwise UIs that trust `resolve` could be steered + // at an unrelated control. + const sdt = createNode('structuredContent', [createNode('text', [], { text: 'Hello' })], { + attrs: { id: '100', tag: 'meta-1' }, + isInline: true, + isBlock: false, + inlineContent: true, + }); + const paragraph = createNode('paragraph', [sdt], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = makeEditor(doc); + // Intentionally no seedPayload call โ€” convertedXml stays empty. + + expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toBeNull(); + }); }); + +/** + * Seed a metadata customXml part directly on the editor's converter. + * Lets tests that pre-seed an SDT in the doc (without going through + * `metadataAttachWrapper`) also wire up the payload side so the + * `metadata.resolve` / `metadata.get` payload gate can find an entry. + */ +function seedPayload( + editor: Editor, + partName: string, + namespace: string, + entries: Array<{ id: string; json: string }>, +): void { + const convertedXml = (editor as unknown as { converter: { convertedXml: Record } }).converter + .convertedXml; + convertedXml[partName] = { + elements: [ + { + type: 'element', + name: 'refs', + attributes: { xmlns: namespace }, + elements: entries.map((entry) => ({ + type: 'element', + name: 'ref', + attributes: { id: entry.id, encoding: 'json' }, + elements: [{ type: 'text', text: entry.json }], + })), + }, + ], + }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts index 1adcdb3869..25ac89125d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts @@ -437,6 +437,15 @@ export function metadataResolveWrapper( editor: Editor, input: AnchoredMetadataResolveInput, ): AnchoredMetadataResolveInfo | null { + // An inline SDT's `w:tag` is not reserved for anchored metadata โ€” + // an imported DOCX can carry foreign content controls whose tag + // happens to match a metadata id. Require both halves of the + // anchor (the SDT in the body and the payload entry in a customXml + // part) to agree before reporting the id resolves, so callers that + // trust `resolve` (including `ui.metadata.*`) cannot be steered at + // an unrelated control. Mirrors what `metadata.get` already does + // for payload reads. + if (!hasPayloadEntry(getConvertedXml(editor), input.id)) return null; const target = resolveAnchorTarget(editor, input.id); return target ? { id: input.id, target } : null; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index a828ee85a4..b5f4ec71e0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -147,6 +147,29 @@ function listCommentAnchorsSafe(editor: Editor): ReturnType, +): void { + const emitter = (editor as unknown as { emit?: (event: string, payload: unknown) => void }).emit; + if (typeof emitter !== 'function') return; + emitter.call(editor, 'commentsUpdate', { type, comment }); +} + function applyTextSelection(editor: Editor, from: number, to: number): boolean { const setTextSelection = editor.commands?.setTextSelection; if (typeof setTextSelection === 'function') { @@ -762,6 +785,8 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, optio }; } + let resolvedTimestamp: number | null = null; + const receipt = executeDomainCommand( editor, () => { @@ -770,10 +795,11 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, optio importedId: identity.importedId, }); if (didResolve) { + resolvedTimestamp = Date.now(); upsertCommentEntity(store, identity.commentId, { importedId: identity.importedId, isDone: true, - resolvedTime: Date.now(), + resolvedTime: resolvedTimestamp, }); } return Boolean(didResolve); @@ -788,6 +814,18 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, optio }; } + // SD-3214: the resolveComment engine command sets `tr.setMeta(CommentsPluginKey, { event: 'update' })` + // but does not emit `commentsUpdate`. The browser commentsStore handles its own resolve flow by + // emitting `comments-update` manually + writing to Y.Array. Document-API consumers (CLI, MCP) need + // the wrapper to fire the canonical event so the headless bridge can propagate to Y.Array via its + // existing `'update'` / `'resolved'` handler. + emitCommentLifecycleUpdate(editor, 'resolved', { + commentId: identity.commentId, + importedId: identity.importedId, + isDone: true, + resolvedTime: resolvedTimestamp, + }); + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } @@ -850,6 +888,16 @@ function reopenCommentHandler(editor: Editor, input: ReopenCommentInput, options }; } + // SD-3214: reopenComment doesn't emit either โ€” surface a canonical + // 'update' event so the bridge can mirror the cleared resolved markers + // into Y.Array. + emitCommentLifecycleUpdate(editor, 'update', { + commentId: identity.commentId, + importedId: identity.importedId, + isDone: false, + resolvedTime: null, + }); + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } @@ -890,6 +938,17 @@ function removeCommentHandler(editor: Editor, input: RemoveCommentInput, options removedIds.add(identity.commentId); } + // SD-3214: removeComment engine command sets `tr.setMeta` but doesn't emit + // `commentsUpdate`. Emit here so the headless bridge propagates the delete + // to Y.Array (and the browser's existing DELETED branch refreshes its Vue + // list). Emits per removed id so thread-reply cascades reach subscribers. + for (const removedId of removedIds) { + emitCommentLifecycleUpdate(editor, 'deleted', { + commentId: removedId, + importedId: removedId === identity.commentId ? identity.importedId : undefined, + }); + } + return { success: true, removed: Array.from(removedIds).map((id) => toCommentAddress(id)), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.integration.test.ts index c8b906208f..fdbad4595f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.integration.test.ts @@ -358,12 +358,13 @@ describe('customXml.parts write-side', () => { const converted = (editor as unknown as { converter: { convertedXml: Record } }).converter .convertedXml; - const relsDoc = converted['word/_rels/document.xml.rels'] as { elements?: Array<{ elements?: Array<{ attributes?: Record }> }> } | undefined; + const relsDoc = converted['word/_rels/document.xml.rels'] as + | { elements?: Array<{ elements?: Array<{ attributes?: Record }> }> } + | undefined; const relsRoot = relsDoc?.elements?.[0]; const customXmlRels = (relsRoot?.elements ?? []).filter( (rel) => - rel?.attributes?.Type === - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml', + rel?.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml', ); expect(customXmlRels.length).toBe(1); expect(customXmlRels[0]!.attributes?.Target).toBe('../customXml/item1.xml'); @@ -442,12 +443,13 @@ describe('customXml.parts write-side', () => { expect(converted['customXml/itemProps1.xml']).toBeUndefined(); expect(converted['customXml/_rels/item1.xml.rels']).toBeUndefined(); - const relsDoc = converted['word/_rels/document.xml.rels'] as { elements?: Array<{ elements?: Array<{ attributes?: Record }> }> } | undefined; + const relsDoc = converted['word/_rels/document.xml.rels'] as + | { elements?: Array<{ elements?: Array<{ attributes?: Record }> }> } + | undefined; const relsRoot = relsDoc?.elements?.[0]; const lingering = (relsRoot?.elements ?? []).filter( (rel) => - rel?.attributes?.Type === - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml', + rel?.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml', ); expect(lingering).toEqual([]); @@ -610,8 +612,9 @@ describe('customXml.parts write-side', () => { // Seed: simulate a doc with a bibliography custom XML part already // loaded. The converter's bibliographyPart cache will hold sources. const editor = await createEditorWithEmptyPackage(); - const converter = (editor as unknown as { converter: { convertedXml: Record; bibliographyPart?: unknown } }) - .converter; + const converter = ( + editor as unknown as { converter: { convertedXml: Record; bibliographyPart?: unknown } } + ).converter; // Fake a populated bibliographyPart cache pointing at customXml/item1.xml. converter.bibliographyPart = { sources: [ @@ -658,7 +661,10 @@ describe('customXml.parts write-side', () => { // After export, convertedXml should NOT have the part again (or, if // it does, that's the staleness bug). const partResurrectedInConvertedXml = converter.convertedXml['customXml/item1.xml'] !== undefined; - expect(partResurrectedInConvertedXml, 'syncBibliographyPartToPackage re-added the removed part to convertedXml').toBe(false); + expect( + partResurrectedInConvertedXml, + 'syncBibliographyPartToPackage re-added the removed part to convertedXml', + ).toBe(false); editor.destroy(); }); @@ -677,10 +683,7 @@ describe('customXml.parts write-side', () => { editor.destroy(); // Reimport from the exported bytes through the canonical loader. - const [reloadedDocx, reloadedMedia, reloadedMediaFiles, reloadedFonts] = await Editor.loadXmlData( - bytes, - true, - ); + const [reloadedDocx, reloadedMedia, reloadedMediaFiles, reloadedFonts] = await Editor.loadXmlData(bytes, true); const { editor: reloaded } = initTestEditor({ content: reloadedDocx, media: reloadedMedia, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts index ff4d266a0b..2033fe1100 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.test.ts @@ -871,3 +871,54 @@ describe('queryMatchAdapter โ€” cardinality', () => { expect(result.total).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// Wire-shape regression guard +// +// Pins the public contract of items[] so callers (LLM tool prompts, agent +// guides) cannot drift back to fields that do not exist on the wire. The +// `superdoc_comment` MCP description previously told agents to use +// `items[0].context.textRanges[0]`, which is a field on `doc.find` output +// (the legacy sibling) and not on `doc.query.match` (where `superdoc_search` +// dispatches). Comments use `TextAddress | TextTarget` built from +// `items[0].blocks[*]`. +// --------------------------------------------------------------------------- + +describe('queryMatchAdapter โ€” wire-shape contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('rev-1'); + }); + + it('emits items without a `context` field; range data lives on blocks[i]', () => { + const candidates = [{ nodeId: 'p1', pos: 0, end: 22, text: 'Title Body text here' }]; + const editor = makeEditorWithBlocks(candidates); + setupBlockIndex(candidates.map(({ nodeId, pos, end }) => ({ nodeId, pos, end }))); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [{ textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 5, end: 17 } }] }], + total: 1, + }); + mockedDeps.captureRunsInRange.mockReturnValue(captured([capturedRun(5, 17, [])])); + + const result = queryMatchAdapter(editor, { + select: { type: 'text', pattern: 'Body text he' }, + }); + + const item = result.items[0] as any; + + // The wire output of `query.match` has no `context` and no `textRanges`. + // Adding either would silently re-enable the previously-broken agent + // guidance that pointed at `items[0].context.textRanges[0]`. + expect(item.context).toBeUndefined(); + expect(item.textRanges).toBeUndefined(); + + // The block-relative range a comment target needs lives on blocks[i].range. + expect(item.blocks[0].blockId).toBe('p1'); + expect(item.blocks[0].range).toEqual({ start: 5, end: 17 }); + + // `target` is a SelectionTarget (kind:'selection'), which `comments.create` + // does NOT accept (it takes TextAddress | TextTarget, kind:'text'). + expect(item.target.kind).toBe('selection'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts index 4059bc1c23..6f1aadbb5f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts @@ -310,4 +310,70 @@ describe('doc.replace multi-paragraph integration', () => { expect(insertedTexts).toEqual(expect.arrayContaining(['Alpha'])); expect(deletedTexts.join('')).toContain('hello world'); }); + + // SD-3044: when the word-diff produces multiple groups with EQUAL tokens + // between them, inserted text used to anchor on the previous result op's + // end instead of the EQUAL token's end, piling all granular insertions on + // the first deletion site. + it('SD-3044: tracked rewrite with shared suffix anchors inserts correctly', () => { + editor = makeEditor(['[insert] of [insert], [insert] ("Investor")']); + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef(editor, '[insert] of [insert], [insert] ("Investor")'), + text: 'John James Smith of [insert address], [insert] ("Investor")', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + + // Accepted view: drop trackDelete marks, keep everything else. + const acceptedParts: string[] = []; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + const isDeleted = node.marks.some((mark: any) => mark.type.name === TrackDeleteMarkName); + if (!isDeleted) acceptedParts.push(node.text); + }); + + expect(acceptedParts.join('')).toBe('John James Smith of [insert address], [insert] ("Investor")'); + + // Specifically guard against the buggy strings reported in the ticket. + const accepted = acceptedParts.join(''); + expect(accepted).not.toContain('JohnJames'); + expect(accepted).not.toContain('Smith address'); + }); + + it('SD-3044: tracked rewrite of long block preserves spacing across multiple equal anchors', () => { + editor = makeEditor([ + '[insert] Pty Limited a company incorporated in Australia having its registered office at [insert] (ACN [insert])("Company")', + ]); + const target = + 'Working Title Group Limited a company incorporated in New Zealand having its registered office at 29 Park Hill Road, Birkenhead, Auckland, 0626, NZ (NZBN 9429050880331)("Company")'; + + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef( + editor, + '[insert] Pty Limited a company incorporated in Australia having its registered office at [insert] (ACN [insert])("Company")', + ), + text: target, + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(true); + + const acceptedParts: string[] = []; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + const isDeleted = node.marks.some((mark: any) => mark.type.name === TrackDeleteMarkName); + if (!isDeleted) acceptedParts.push(node.text); + }); + + const accepted = acceptedParts.join(''); + expect(accepted).toBe(target); + expect(accepted).not.toContain('PtyTitle'); + expect(accepted).not.toContain('AustraliaNew'); + expect(accepted).not.toContain('(ACNPark'); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.test.ts new file mode 100644 index 0000000000..4444376994 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { getWordChanges, type WordDiffOp } from './word-diff.ts'; + +function applyOps(oldText: string, ops: WordDiffOp[]): string { + // Apply word ops to oldText to produce the expected new text. Ops anchor on + // oldText offsets and are applied left-to-right with cumulative offset. + let result = ''; + let cursor = 0; + for (const op of ops) { + if (op.type === 'insert') { + // Copy unchanged text up to the insertion point, then insert. + result += oldText.slice(cursor, op.insertAt); + cursor = op.insertAt; + result += op.newText; + } else if (op.type === 'delete') { + result += oldText.slice(cursor, op.oldFrom); + cursor = op.oldTo; + } else { + result += oldText.slice(cursor, op.oldFrom); + cursor = op.oldTo; + result += op.newText; + } + } + result += oldText.slice(cursor); + return result; +} + +describe('getWordChanges', () => { + it('returns empty for identical text', () => { + expect(getWordChanges('hello world', 'hello world')).toEqual([]); + }); + + it('returns single insert when old is empty', () => { + expect(getWordChanges('', 'hello')).toEqual([{ type: 'insert', insertAt: 0, newText: 'hello' }]); + }); + + it('returns single delete when new is empty', () => { + expect(getWordChanges('hello', '')).toEqual([{ type: 'delete', oldFrom: 0, oldTo: 5 }]); + }); + + it('produces correct REPLACE for a single word change', () => { + const ops = getWordChanges('hello world', 'goodbye world'); + expect(applyOps('hello world', ops)).toBe('goodbye world'); + }); + + it('produces correct ops when one word is replaced and the trailing one is kept', () => { + const ops = getWordChanges('foo bar', 'baz bar'); + expect(applyOps('foo bar', ops)).toBe('baz bar'); + }); + + // SD-3044: regression โ€” insert-only groups between EQUAL tokens must anchor + // to the preceding EQUAL token's end, not to the previous result op's end. + it('SD-3044: insert between EQUAL tokens uses correct anchor', () => { + // Pattern: old has an EQUAL token that lands between two insert groups. + const ops = getWordChanges('a b c', 'x a y b c'); + expect(applyOps('a b c', ops)).toBe('x a y b c'); + }); + + it('SD-3044: regression with the exact suffix-trim shape from the Lighthouse fixture', () => { + // After prefix/suffix trim, the parties-investor block reduces to these + // strings. The trailing `]` of the second `[insert]` is in the suffix, so + // `[insert` (without the `]`) becomes a token that matches between old and + // new. Myers then produces three groups separated by EQUAL tokens โ€” the + // bug was that the two pure-INSERT groups both anchored to char 8. + const oldTrimmed = '[insert] of [insert'; + const newTrimmed = 'John James Smith of [insert address'; + const ops = getWordChanges(oldTrimmed, newTrimmed); + expect(applyOps(oldTrimmed, ops)).toBe(newTrimmed); + + // Specifically the bug produced two inserts at insertAt=8; with the fix, + // the second insert anchors past the preserved ` of [insert` (offset 19). + const inserts = ops.filter((o): o is Extract => o.type === 'insert'); + expect(inserts).toHaveLength(2); + const insertAts = inserts.map((o) => o.insertAt).sort((a, b) => a - b); + expect(insertAts[0]).toBe(9); // after the equal space (old[1]) + expect(insertAts[1]).toBe(19); // after the equal `[insert` (old[4]) + }); + + it('SD-3044: prefix-only equal anchors first insert past the prefix', () => { + const ops = getWordChanges('foo', 'foo bar'); + expect(applyOps('foo', ops)).toBe('foo bar'); + // After EQUAL `foo` (length 3), insert anchor must be 3 not 0. + const inserts = ops.filter((o): o is Extract => o.type === 'insert'); + if (inserts.length > 0) { + expect(inserts[0].insertAt).toBe(3); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts index 6c94df3daa..afaf10311c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts @@ -87,6 +87,13 @@ export function getWordChanges(oldText: string, newText: string): WordDiffOp[] { continue; } + // SD-3044: capture the index where this delete/insert group starts so we + // can inspect the step immediately preceding the group (typically an + // 'equal' that anchors a pure-insert group's position). After the inner + // while loop runs, `i` points past the group, so `steps[i - 1]` is the + // last delete/insert in this group and never reflects the prior anchor. + const groupStart = i; + let deleteStart = -1; let deleteEnd = -1; let insertText = ''; @@ -108,7 +115,7 @@ export function getWordChanges(oldText: string, newText: string): WordDiffOp[] { } else if (deleteStart !== -1) { result.push({ type: 'delete', oldFrom: deleteStart, oldTo: deleteEnd }); } else if (insertText.length > 0) { - const prevStep = i > 0 ? steps[i - 1] : null; + const prevStep = groupStart > 0 ? steps[groupStart - 1] : null; let insertAt = 0; if (prevStep && prevStep.type === 'equal') { const prevToken = oldTokens[prevStep.oldIdx]; diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotations.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotations.js index 105e283e7d..bba185ec63 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotations.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotations.js @@ -1,5 +1,14 @@ import { getAllFieldAnnotations } from './getAllFieldAnnotations.js'; +/** + * Find field annotations matching a predicate. + * + * @param {(node: import('./types.js').PmNode) => boolean} predicate - + * Called with each `fieldAnnotation` node; return `true` to keep the entry. + * @param {import('./types.js').EditorState} state - The editor state to search. + * @returns {import('./types.js').FieldAnnotationEntry[]} Matching + * `{ node, pos }` entries. + */ export function findFieldAnnotations(predicate, state) { let allFieldAnnotations = getAllFieldAnnotations(state); let fieldAnnotations = []; diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsBetween.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsBetween.js index 00df24cd30..205ebfe3d1 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsBetween.js @@ -1,9 +1,11 @@ /** - * Find all field annotations between positions. - * @param from From position. - * @param to To position. - * @param doc Document. - * @returns The array of field annotations (node and pos). + * Find all field annotations between two document positions. + * + * @param {number} from - Start position (inclusive). + * @param {number} to - End position (exclusive). + * @param {import('./types.js').PmNode} doc - Document node to scan. + * @returns {import('./types.js').FieldAnnotationEntry[]} `{ node, pos }` + * per annotation in range. */ export function findFieldAnnotationsBetween(from, to, doc) { let fieldAnnotations = []; diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsByFieldId.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsByFieldId.js index ca6a464f13..6d091e3263 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsByFieldId.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsByFieldId.js @@ -2,9 +2,12 @@ import { findChildren } from '@core/helpers/findChildren.js'; /** * Find field annotations by field ID or array of field IDs. - * @param fieldIdOrArray The field ID or array of field IDs. - * @param state The editor state. - * @returns The field annotations array. + * + * @param {string | string[]} fieldIdOrArray - Single field ID or array + * of IDs to match against `node.attrs.fieldId`. + * @param {import('./types.js').EditorState} state - The editor state to search. + * @returns {import('./types.js').FieldAnnotationEntry[]} Matching + * `{ node, pos }` entries. */ export function findFieldAnnotationsByFieldId(fieldIdOrArray, state) { let fieldAnnotations = findChildren(state.doc, (node) => { diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFirstFieldAnnotationByFieldId.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFirstFieldAnnotationByFieldId.js index 9caadb3f7f..3b888ec714 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFirstFieldAnnotationByFieldId.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFirstFieldAnnotationByFieldId.js @@ -1,8 +1,10 @@ /** - * Find first field annotation by field ID. - * @param fieldId The field ID. - * @param state The editor state. - * @returns The field annotation or null. + * Find the first field annotation matching the given field ID. + * + * @param {string} fieldId - The field ID to match against `node.attrs.fieldId`. + * @param {import('./types.js').EditorState} state - The editor state to search. + * @returns {import('./types.js').FieldAnnotationEntry | null} The first + * match, or `null` if none. */ export function findFirstFieldAnnotationByFieldId(fieldId, state) { let fieldAnnotation = findNode(state.doc, (node) => { @@ -12,7 +14,13 @@ export function findFirstFieldAnnotationByFieldId(fieldId, state) { return fieldAnnotation; } +/** + * @param {import('./types.js').PmNode} node + * @param {(node: import('./types.js').PmNode) => boolean} predicate + * @returns {import('./types.js').FieldAnnotationEntry | null} + */ function findNode(node, predicate) { + /** @type {import('./types.js').FieldAnnotationEntry | null} */ let found = null; node.descendants((node, pos) => { if (predicate(node)) found = { node, pos }; diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findHeaderFooterAnnotationsByFieldId.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findHeaderFooterAnnotationsByFieldId.js index b152f56454..a594795141 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findHeaderFooterAnnotationsByFieldId.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findHeaderFooterAnnotationsByFieldId.js @@ -2,10 +2,20 @@ import { findChildren } from '@core/helpers/findChildren.js'; import { getAllHeaderFooterEditors } from '../../../core/helpers/annotator.js'; /** - * Find field annotations in headers and footers by field ID or array of field IDs. - * @param fieldIdOrArray The field ID or array of field IDs. - * @param editor The editor state. - * @returns The field annotations array. + * Find field annotations across all header / footer sub-editors that + * match the given field ID(s). If the active section editor's + * `documentId` matches a sub-editor, that sub-editor's live state is + * used instead of its snapshot state. + * + * @param {string | string[]} fieldIdOrArray - Field ID or array of IDs + * to match against `node.attrs.fieldId`. + * @param {import('./types.js').Editor} editor - The main editor whose + * registered headers / footers are walked. + * @param {import('./types.js').Editor} activeSectionEditor - The + * currently-focused section sub-editor; its `state` overrides the + * snapshot state for a matching `documentId`. + * @returns {import('./types.js').FieldAnnotationEntry[]} `{ node, pos }` + * per match across all header / footer sub-editors. */ export function findHeaderFooterAnnotationsByFieldId(fieldIdOrArray, editor, activeSectionEditor) { const sectionEditors = getAllHeaderFooterEditors(editor); diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js index 0c75c3ee42..3c80536a32 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js @@ -1,7 +1,25 @@ import { ReplaceStep } from 'prosemirror-transform'; import { findChildren } from '@core/helpers/findChildren'; +/** + * Find field annotations that were removed by a transaction. + * + * Inspects the transaction's `ReplaceStep`s against `tr.before` and + * returns annotations whose positions were deleted (and that didn't + * reappear elsewhere in `tr.doc` under the same `fieldId`). + * + * Skips when: + * - the transaction has no steps, + * - it carries unexpected meta keys, + * - it's an undo / redo / drop / fieldAnnotationUpdate / tableGeneration tx. + * + * @param {import('./types.js').Transaction} tr - The transaction to inspect. + * @returns {import('./types.js').FieldAnnotationEntry[]} Removed + * `{ node, pos }` entries. Empty array when nothing was removed or the + * transaction is skipped. + */ export function findRemovedFieldAnnotations(tr) { + /** @type {import('./types.js').FieldAnnotationEntry[]} */ let removedNodes = []; if ( @@ -49,6 +67,10 @@ export function findRemovedFieldAnnotations(tr) { return removedNodes; } +/** + * @param {import('./types.js').Transaction} tr + * @returns {boolean} + */ function transactionDeletedAnything(tr) { return tr.steps.some((step) => { if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotations.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotations.js index 307a03ab10..7ad85a05ea 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotations.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotations.js @@ -1,9 +1,12 @@ import { findChildren } from '@core/helpers/findChildren.js'; /** - * Get all field annotations in the doc. - * @param state The editor state. - * @returns The array of field annotations. + * Get all field annotations in the document. + * + * @param {import('./types.js').EditorState} state - The editor state to search. + * @returns {import('./types.js').FieldAnnotationEntry[]} Array of + * `{ node, pos }` entries where `node.type.name === 'fieldAnnotation'`. + * Empty array if none. */ export function getAllFieldAnnotations(state) { let fieldAnnotations = findChildren(state.doc, (node) => node.type.name === 'fieldAnnotation'); diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotationsWithRect.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotationsWithRect.js index 690296acf5..3cfc4db2c9 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotationsWithRect.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotationsWithRect.js @@ -2,10 +2,15 @@ import { posToDOMRect } from '@core/helpers/index.js'; import { getAllFieldAnnotations } from './getAllFieldAnnotations.js'; /** - * Get all field annotations with rects in the doc. - * @param view The editor view. - * @param state The editor state. - * @returns The array of field annotations with rects. + * Get all field annotations in the document, paired with their DOM + * bounding rect from the current view. + * + * @param {import('./types.js').EditorView} view - The editor view; used + * to compute DOM rects. + * @param {import('./types.js').EditorState} state - The editor state to search. + * @returns {import('./types.js').FieldAnnotationEntryWithRect[]} + * `{ node, pos, rect }` per annotation. `rect` is the bounding rect + * from `view.coordsAtPos`. */ export function getAllFieldAnnotationsWithRect(view, state) { let fieldAnnotations = getAllFieldAnnotations(state).map(({ node, pos }) => { diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getHeaderFooterAnnotations.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getHeaderFooterAnnotations.js index 63f2c49adb..4822df75b1 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getHeaderFooterAnnotations.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getHeaderFooterAnnotations.js @@ -2,8 +2,12 @@ import { getAllHeaderFooterEditors } from '@core/helpers/annotator.js'; import { getAllFieldAnnotations } from './index.js'; /** - * Get all field annotations in the header and footer. - * @returns {Object[]} An array of field annotations, and which editor they belong to. + * Get all field annotations across every header / footer sub-editor. + * + * @param {import('./types.js').Editor} editor - The main editor whose + * registered headers / footers are walked. + * @returns {import('./types.js').FieldAnnotationEntry[]} `{ node, pos }` + * per annotation, flattened across all sub-editors. */ export const getHeaderFooterAnnotations = (editor) => { const editors = getAllHeaderFooterEditors(editor); diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/trackFieldAnnotationsDeletion.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/trackFieldAnnotationsDeletion.js index d24eeb5ca3..669e6ea411 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/trackFieldAnnotationsDeletion.js +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/trackFieldAnnotationsDeletion.js @@ -1,6 +1,20 @@ import { findRemovedFieldAnnotations } from './findRemovedFieldAnnotations.js'; +/** + * Detect field annotations removed by the transaction and emit a + * `fieldAnnotationDeleted` event on the editor (deferred via + * `setTimeout(0)` so the dispatch settles before subscribers run). + * + * Failures inside `findRemovedFieldAnnotations` are swallowed so a + * transaction-shape edge case can't crash the editor. + * + * @param {import('./types.js').Editor} editor - The editor instance to + * emit the event on. + * @param {import('./types.js').Transaction} tr - The transaction to inspect. + * @returns {void} + */ export function trackFieldAnnotationsDeletion(editor, tr) { + /** @type {import('./types.js').FieldAnnotationEntry[]} */ let removedAnnotations = []; try { removedAnnotations = findRemovedFieldAnnotations(tr); diff --git a/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/types.js b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/types.js new file mode 100644 index 0000000000..8aeaf7abef --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/types.js @@ -0,0 +1,20 @@ +/** + * Shared JSDoc typedefs for the `fieldAnnotationHelpers` namespace. + * + * Defined once here so each helper file can reference them via + * `@typedef {import('./types.js').X}` without re-declaring (which + * would trigger ambiguous `export *` re-exports at the index barrel). + * + * @typedef {import('prosemirror-state').EditorState} EditorState + * @typedef {import('prosemirror-state').Transaction} Transaction + * @typedef {import('prosemirror-view').EditorView} EditorView + * @typedef {import('prosemirror-model').Node} PmNode + * @typedef {import('../../../core/Editor.js').Editor} Editor + * + * @typedef {{ node: PmNode; pos: number }} FieldAnnotationEntry + * @typedef {{ node: PmNode; pos: number; rect: DOMRect }} FieldAnnotationEntryWithRect + */ + +// Module marker so TypeScript treats this as a module-scoped declaration +// file rather than a script. No runtime symbols are exported. +export {}; diff --git a/packages/super-editor/src/editors/v1/extensions/index.d.ts b/packages/super-editor/src/editors/v1/extensions/index.d.ts index bd08f0006d..56585d28ab 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.d.ts +++ b/packages/super-editor/src/editors/v1/extensions/index.d.ts @@ -1,2 +1,20 @@ -export function getRichTextExtensions(...args: any[]): any[]; -export function getStarterExtensions(...args: any[]): any[]; +import type { EditorExtension } from '../core/types/EditorConfig.js'; + +/** + * Returns the default extension set used for rich-text documents. + * + * Runtime takes no arguments; the previous `(...args: any[]): any[]` + * signature was incorrect on both sides (no call site passes args; + * the return is a concrete `EditorExtension[]` from the public + * EditorConfig type union). SD-3213 drain. + */ +export function getRichTextExtensions(): EditorExtension[]; + +/** + * Returns the default extension set used for DOCX documents (superset + * of `getRichTextExtensions`). + * + * Runtime takes no arguments; see the JSDoc on `getRichTextExtensions` + * for the SD-3213 rationale. + */ +export function getStarterExtensions(): EditorExtension[]; diff --git a/packages/super-editor/src/editors/v1/extensions/link/link.js b/packages/super-editor/src/editors/v1/extensions/link/link.js index 61da065961..4951e895d2 100644 --- a/packages/super-editor/src/editors/v1/extensions/link/link.js +++ b/packages/super-editor/src/editors/v1/extensions/link/link.js @@ -1,4 +1,5 @@ // @ts-nocheck +import { TextSelection } from 'prosemirror-state'; import { Mark } from '@core/Mark.js'; import { Attribute } from '@core/Attribute.js'; import { getMarkRange } from '@core/helpers/getMarkRange.js'; @@ -231,18 +232,27 @@ export const Link = Mark.create({ if (underlineMarkType) { const rangesMissingUnderline = []; + const negationMarksToRemove = []; tr.doc.nodesBetween(from, to, (node, pos) => { if (!node.isText || node.nodeSize <= 0) return; - const hasUnderline = node.marks.some((mark) => mark.type === underlineMarkType); - if (hasUnderline) return; + // underlineType: 'none' is a negation marker (renders as , not ), + // not a visible underline โ€” treat it as missing so the link gets one. + const existing = node.marks.find((mark) => mark.type === underlineMarkType); + const hasVisibleUnderline = existing && existing.attrs?.underlineType !== 'none'; + if (hasVisibleUnderline) return; - // Only apply while overlapping with current selection/link range const rangeFrom = Math.max(pos, from); const rangeTo = Math.min(pos + node.nodeSize, to); if (rangeFrom >= rangeTo) return; + if (existing && existing.attrs?.underlineType === 'none') { + negationMarksToRemove.push({ from: rangeFrom, to: rangeTo, mark: existing }); + } rangesMissingUnderline.push({ from: rangeFrom, to: rangeTo }); }); + negationMarksToRemove.forEach((range) => { + tr = tr.removeMark(range.from, range.to, range.mark); + }); rangesMissingUnderline.forEach((range) => { tr = tr.addMark(range.from, range.to, underlineMarkType.create({ autoAdded: true })); }); @@ -283,14 +293,36 @@ export const Link = Mark.create({ let { from, to } = selection; if (selection.empty && linkMarkType) { - const range = getMarkRange(selection.$from, linkMarkType); - if (range) { - from = range.from; - to = range.to; + const initialRange = getMarkRange(selection.$from, linkMarkType); + if (initialRange) { + from = initialRange.from; + to = initialRange.to; + } else { + // Imported DOCX links can sit at node boundaries where getMarkRange misses. + const doc = state.doc; + const docSize = doc.content.size; + const probePositions = [selection.from - 1, selection.from, selection.from + 1].filter( + (pos) => pos >= 0 && pos <= docSize, + ); + + for (const pos of probePositions) { + const range = getMarkRange(doc.resolve(pos), linkMarkType); + if (range) { + from = range.from; + to = range.to; + break; + } + } } } const commandChain = chain(); + if (selection.empty && linkMarkType && from !== to) { + commandChain.command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, from, to)); + return true; + }); + } return commandChain .unsetColor() @@ -345,6 +377,28 @@ export const Link = Mark.create({ ); }); + // Imported DOCX links can still exist as run node marks, remove too. + if (linkMarkType) { + const runNodeMarkRemovals = []; + tr.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name !== 'run') return; + if (!node.marks.some((mark) => mark.type === linkMarkType)) return; + runNodeMarkRemovals.push(pos); + }); + + runNodeMarkRemovals.reverse().forEach((pos) => { + const mappedPos = tr.mapping.map(pos); + const runNode = tr.doc.nodeAt(mappedPos); + if (!runNode) return; + tr.setNodeMarkup( + mappedPos, + runNode.type, + runNode.attrs, + runNode.marks.filter((mark) => mark.type !== linkMarkType), + ); + }); + } + return true; }) .run(); diff --git a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.test.js b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.test.js index 4b38325c1c..2abc8885e6 100644 --- a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.test.js @@ -1162,7 +1162,12 @@ describe('calculateInlineRunPropertiesPlugin', () => { { name: 'A4: ascii-only theme preserved', existing: { asciiTheme: 'majorBidi' }, - marksFontFamily: { ascii: 'Calibri Light', hAnsi: 'Calibri Light', eastAsia: 'Calibri Light', cs: 'Calibri Light' }, + marksFontFamily: { + ascii: 'Calibri Light', + hAnsi: 'Calibri Light', + eastAsia: 'Calibri Light', + cs: 'Calibri Light', + }, // hAnsi/eastAsia/cs are mark-derived concrete; only asciiTheme had a theme to preserve. expected: { asciiTheme: 'majorBidi', @@ -1185,7 +1190,12 @@ describe('calculateInlineRunPropertiesPlugin', () => { { name: 'A6: cs-only theme preserved (lowercase `cstheme` OOXML quirk)', existing: { cstheme: 'majorBidi' }, - marksFontFamily: { ascii: 'Calibri Light', hAnsi: 'Calibri Light', eastAsia: 'Calibri Light', cs: 'Calibri Light' }, + marksFontFamily: { + ascii: 'Calibri Light', + hAnsi: 'Calibri Light', + eastAsia: 'Calibri Light', + cs: 'Calibri Light', + }, expected: { ascii: 'Calibri Light', hAnsi: 'Calibri Light', @@ -1287,12 +1297,7 @@ describe('calculateInlineRunPropertiesPlugin', () => { resolveRunPropertiesMock.mockImplementation(() => ({})); const schema = makeSchema(); - const doc = paragraphDoc( - schema, - { runProperties: null, runPropertiesInlineKeys: null }, - [], - 'Latin', - ); + const doc = paragraphDoc(schema, { runProperties: null, runPropertiesInlineKeys: null }, [], 'Latin'); const state = createState(schema, doc); const { from, to } = runTextRange(state.doc, 0, 5); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/documentHelpers.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/documentHelpers.js index 6d8b04f838..63b225e787 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/documentHelpers.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/documentHelpers.js @@ -1,4 +1,17 @@ // https://discuss.prosemirror.net/t/expanding-the-selection-to-the-active-mark/ + +/** + * Expand from `pos` to the full range covered by the mark named + * `markName` on the containing parent. Walks left/right while adjacent + * siblings share an equivalent mark. + * + * @param {import('./types.js').PmNode} doc - Document root to resolve against. + * @param {number} pos - Cursor position to expand from. + * @param {string} markName - Mark type name (e.g. `'link'`, `'bold'`). + * @returns {{ from: number; to: number; attrs: import('./types.js').Attrs } | null} + * `{ from, to, attrs }` of the contiguous mark range, or `null` if no + * `markName` mark is at `pos`. + */ export const findMarkPosition = (doc, pos, markName) => { const $pos = doc.resolve(pos); const parent = $pos.parent; @@ -34,10 +47,21 @@ export const findMarkPosition = (doc, pos, markName) => { }; }; +/** + * Flatten a document tree into a list of `{ node, pos }` entries. + * + * @param {import('./types.js').PmNode} node - Root to flatten. + * @param {boolean} [descend=true] - Recurse into matching children. + * When `false`, only the top-level descendants are returned. + * @returns {import('./types.js').NodePosEntry[]} Every descendant + * paired with its document position. + * @throws {Error} If `node` is missing. + */ export const flatten = (node, descend = true) => { if (!node) { throw new Error('Invalid "node" parameter'); } + /** @type {import('./types.js').NodePosEntry[]} */ const result = []; node.descendants((child, pos) => { result.push({ node: child, pos }); @@ -48,6 +72,20 @@ export const flatten = (node, descend = true) => { return result; }; +/** + * Track-changes variant of `findChildren` with optional descend control. + * Distinct from `@core/helpers/findChildren` (the 2-arg version without + * `descend`); prefer the core helper for the common case. + * + * @param {import('./types.js').PmNode} node - Root to search. + * @param {(child: import('./types.js').PmNode) => boolean} predicate - + * Called per descendant; return `true` to keep the entry. + * @param {boolean} [descend] - Recurse into matching children. Forwarded + * to `flatten`. + * @returns {import('./types.js').NodePosEntry[]} Matching `{ node, pos }` + * entries. + * @throws {Error} If `node` or `predicate` is missing. + */ export const findChildren = (node, predicate, descend) => { if (!node) { throw new Error('Invalid "node" parameter'); @@ -57,6 +95,13 @@ export const findChildren = (node, predicate, descend) => { return flatten(node, descend).filter((child) => predicate(child.node)); }; +/** + * Return every inline-typed descendant of `node` as `{ node, pos }`. + * + * @param {import('./types.js').PmNode} node - Root to search. + * @param {boolean} [descend] - Recurse into matching children. + * @returns {import('./types.js').NodePosEntry[]} Inline descendants. + */ export const findInlineNodes = (node, descend) => { return findChildren(node, (child) => child.isInline, descend); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index a7a2a9e412..449ff7d4bb 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -1,5 +1,25 @@ /** - * Find tracked mark between positions by mark name and attrs. + * Find a tracked mark in a document range by mark name and (optionally) + * a partial attrs match. Returns the first hit; expands by `offset` + * around the requested range so non-inclusive marks just outside it + * are still considered. If nothing matches inside the range, falls + * back to inspecting nodes adjacent to the range boundaries (handles + * Google-Docs-style text inserted directly under a paragraph without + * a wrapping run, and Firefox-vs-Chrome wrapping differences). + * + * @param {object} args + * @param {import('./types.js').Transaction} args.tr - Transaction + * whose `tr.doc` is searched. + * @param {number} args.from - Range start. + * @param {number} args.to - Range end. + * @param {string} args.markName - Mark type name to match. + * @param {import('./types.js').Attrs} [args.attrs] - Partial attrs + * to match; every key listed must equal the candidate's attr value. + * Defaults to `{}` (no attr constraint). + * @param {number} [args.offset] - Expand the range by this many + * positions on each side. Defaults to `1` to catch non-inclusive marks. + * @returns {import('./types.js').TrackedMarkRange | null} The first + * match `{ from, to, mark }`, or `null` if no candidate matches. */ export const findTrackedMarkBetween = ({ tr, @@ -14,6 +34,7 @@ export const findTrackedMarkBetween = ({ const startPos = Math.max(from - offset, 0); // $from.start() const endPos = Math.min(to + offset, doc.content.size); // $from.end() + /** @type {import('./types.js').TrackedMarkRange | null} */ let markFound = null; const tryMatch = (node, pos) => { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js index 6d273a4c0c..affbab317f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js @@ -1,4 +1,15 @@ +/** + * Collect the live PM marks present on inline nodes in `[from, to]`, + * deduplicated by `${typeName}:${attrsJson}`. + * + * @param {object} args + * @param {import('./types.js').PmNode} args.doc - Document to scan. + * @param {number} args.from - Range start (inclusive). + * @param {number} args.to - Range end (exclusive). + * @returns {import('./types.js').PmMark[]} Unique inline marks in range. + */ export const getLiveInlineMarksInRange = ({ doc, from, to }) => { + /** @type {import('./types.js').PmMark[]} */ const marks = []; const seen = new Set(); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js index 4450620adc..bc01183934 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js @@ -2,17 +2,24 @@ import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '. import { findInlineNodes } from './documentHelpers.js'; /** - * Get track changes marks. + * Get the tracked-change marks in the document. Each entry pairs the + * live PM mark with the `[from, to]` range of its bearing inline node. + * When `id` is supplied, the result is filtered to marks whose + * `attrs.id` equals it (used to find every range belonging to one + * tracked-change group). * - * Tolerates a missing or partially-initialized state and returns an empty array - * instead of throwing. Comment-import bootstrap can call this through a - * setTimeout(0) before the editor's PM state is attached (SD-2641). + * Tolerates a missing or partially-initialized state and returns an + * empty array instead of throwing. Comment-import bootstrap can call + * this through a `setTimeout(0)` before the editor's PM state is + * attached (SD-2641). * - * @param {import('prosemirror-state').EditorState | null | undefined} state - * @param {string} [id] - * @returns {Array} Array with track changes marks. + * @param {import('./types.js').EditorState | null | undefined} state + * @param {string | null} [id] - Filter to marks with this `attrs.id`. + * @returns {import('./types.js').TrackedMarkRange[]} `{ mark, from, to }` + * per tracked-change mark, optionally filtered by id. */ export const getTrackChanges = (state, id = null) => { + /** @type {import('./types.js').TrackedMarkRange[]} */ const trackedChanges = []; if (!state?.doc) return trackedChanges; const allInlineNodes = findInlineNodes(state.doc); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js index 8cb4bcabfe..3a0f81accf 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -1,9 +1,17 @@ import { isEqual, isMatch } from 'lodash'; +/** + * @param {import('./types.js').Attrs} [attrs] + * @returns {import('./types.js').Attrs} + */ const normalizeAttrs = (attrs = {}) => { return Object.fromEntries(Object.entries(attrs).filter(([, value]) => value !== null && value !== undefined)); }; +/** + * @param {import('./types.js').Attrs} [attrs] + * @returns {import('./types.js').Attrs} + */ const stripUnsetInternalSnapshotAttrs = (attrs = {}) => { const nextAttrs = { ...attrs }; if (nextAttrs.ooxmlHighlightClear === null || nextAttrs.ooxmlHighlightClear === undefined) { @@ -12,6 +20,17 @@ const stripUnsetInternalSnapshotAttrs = (attrs = {}) => { return nextAttrs; }; +/** + * Create a `MarkSnapshot` from a mark type name and attribute bag. + * Internal snapshot attrs (e.g. `ooxmlHighlightClear`) are stripped + * when unset so equality checks are stable. The returned snapshot + * always has `attrs` set (defaulting to `{}`). + * + * @param {string} type - The PM mark type name (e.g. `'bold'`). + * @param {import('./types.js').Attrs} [attrs] - Attribute bag for the + * snapshot. Defaults to `{}`. + * @returns {import('./types.js').MarkSnapshot} Normalized snapshot. + */ export const createMarkSnapshot = (type, attrs = {}) => { return { type, @@ -38,20 +57,44 @@ const ATTRIBUTE_ONLY_MARKS = ['textStyle']; /** * Normalize snapshot attrs for tracked change comparison. * Strips null/undefined AND identity values that represent the default visual state. + * + * @param {import('./types.js').Attrs} [attrs] + * @returns {import('./types.js').Attrs} */ const normalizeSnapshotAttrs = (attrs = {}) => { const base = normalizeAttrs(attrs); return Object.fromEntries(Object.entries(base).filter(([key, value]) => IDENTITY_ATTR_VALUES[key] !== value)); }; +/** + * Extract the mark type name from either a live PM `Mark` (where + * `.type` is a `MarkType` object) or a `MarkSnapshot` (where `.type` + * is already a string). + * + * @param {import('./types.js').MarkLike | null | undefined} markLike + * @returns {string | undefined} The mark type name, or `undefined` if + * the input is missing a `.type`. + */ export const getTypeName = (markLike) => { return markLike?.type?.name ?? markLike?.type; }; /** - * Check if a tracked format change is effectively a no-op. - * Compares before and after snapshots after normalizing identity attribute values. - * A no-op means the format change has no net visual effect. + * Check whether a tracked format change is effectively a no-op. + * Compares before/after snapshot lists after normalizing identity + * attribute values and removing attribute-only marks (e.g. `textStyle`) + * whose normalized attrs are empty. + * + * Accepts the permissive `SnapshotLike[]` shape because the caller + * sources these lists from `formatChangeMark.attrs.before/after`, an + * attribute bag that TS infers loosely. Runtime is tolerant of missing + * fields (`getTypeName` returns `undefined`, attr merges guard with + * `attrs || {}`). + * + * @param {import('./types.js').SnapshotLike[]} before + * @param {import('./types.js').SnapshotLike[]} after + * @returns {boolean} `true` when before and after produce the same + * visual state. */ export const isTrackFormatNoOp = (before, after) => { const normalize = (entries) => @@ -78,12 +121,27 @@ export const isTrackFormatNoOp = (before, after) => { ); }; +/** + * Compare two attribute bags for exact equality after normalizing + * null/undefined entries out of each side. + * + * @param {import('./types.js').Attrs} [left] + * @param {import('./types.js').Attrs} [right] + * @returns {boolean} + */ export const attrsExactlyMatch = (left = {}, right = {}) => { const normalizedLeft = normalizeAttrs(left); const normalizedRight = normalizeAttrs(right); return isEqual(normalizedLeft, normalizedRight); }; +/** + * @param {import('./types.js').MarkLike | null | undefined} left + * @param {import('./types.js').MarkLike | null | undefined} right + * @param {boolean} [exact=true] - When `false`, only type names need to + * match (attrs are ignored). + * @returns {boolean} + */ const marksMatch = (left, right, exact = true) => { if (!left || !right || getTypeName(left) !== getTypeName(right)) { return false; @@ -96,16 +154,47 @@ const marksMatch = (left, right, exact = true) => { return attrsExactlyMatch(left.attrs || {}, right.attrs || {}); }; +/** + * Check whether a snapshot matches a step mark (either by full attr + * equality or by type name only, depending on `exact`). + * + * @param {import('./types.js').MarkLike} snapshot + * @param {import('./types.js').MarkLike} stepMark + * @param {boolean} [exact=true] + * @returns {boolean} + */ export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => { return marksMatch(snapshot, stepMark, exact); }; +/** + * Check whether any mark in `marks` matches `stepMark` exactly + * (same type name and attrs). + * + * @param {import('./types.js').MarkLike[]} marks + * @param {import('./types.js').MarkLike} stepMark + * @returns {boolean} + */ export const hasMatchingMark = (marks, stepMark) => { return marks.some((mark) => { return marksMatch(mark, stepMark, true); }); }; +/** + * Insert or update a snapshot in `snapshots` keyed by `type`. Merges + * `incoming.attrs` over the existing entry's attrs when a match exists; + * otherwise appends a freshly normalized snapshot. + * + * Accepts the permissive `SnapshotLike[]` input (callers may source + * from `formatChangeMark.attrs.*`). Output is the strict + * `MarkSnapshot[]` because `createMarkSnapshot` is the only path for + * new entries and it always normalizes. + * + * @param {import('./types.js').SnapshotLike[]} snapshots - Current set. + * @param {import('./types.js').MarkSnapshot} incoming - Snapshot to merge in. + * @returns {import('./types.js').MarkSnapshot[]} New array (input not mutated). + */ export const upsertMarkSnapshotByType = (snapshots, incoming) => { const existing = snapshots.find((mark) => mark.type === incoming.type); if (existing) { @@ -118,10 +207,22 @@ export const upsertMarkSnapshotByType = (snapshots, incoming) => { return [...snapshots, createMarkSnapshot(incoming.type, incoming.attrs)]; }; +/** + * @param {import('./types.js').MarkLike | null | undefined} mark + * @param {import('./types.js').MarkLike | null | undefined} snapshot + * @param {boolean} [exact=true] + * @returns {boolean} + */ const markMatchesSnapshot = (mark, snapshot, exact = true) => { return marksMatch(mark, snapshot, exact); }; +/** + * @param {import('./types.js').PmMark | null | undefined} mark - Live PM mark. + * @param {import('./types.js').MarkSnapshot | null | undefined} snapshot + * @returns {boolean} `true` when the live mark's attrs are a superset of + * the snapshot's normalized attrs (and snapshot has at least one attr). + */ const markAttrsIncludeSnapshotAttrs = (mark, snapshot) => { if (!mark || !snapshot || mark.type.name !== snapshot.type) { return false; @@ -140,6 +241,11 @@ const markAttrsIncludeSnapshotAttrs = (mark, snapshot) => { // Attribute-only marks (like textStyle) can be serialized with different attr density // between snapshot and live state. This overlap matcher lets reject find the live mark // when exact/subset comparisons fail but shared attrs still clearly identify the mark. +/** + * @param {import('./types.js').PmMark | null | undefined} mark + * @param {import('./types.js').MarkSnapshot | null | undefined} snapshot + * @returns {boolean} + */ const markAttrsMatchOnOverlap = (mark, snapshot) => { if (!mark || !snapshot || mark.type.name !== snapshot.type) { return false; @@ -166,6 +272,20 @@ const markAttrsMatchOnOverlap = (mark, snapshot) => { return overlapKeys.every((key) => isEqual(normalizedMarkAttrs[key], normalizedSnapshotAttrs[key])); }; +/** + * Find the live PM mark in `[from, to]` that best matches `snapshot`. + * Priority: exact attr match โ†’ snapshot-subset โ†’ attribute overlap โ†’ + * type-only fallback (only when snapshot has no attrs). Returns `null` + * if no candidate is found. + * + * @param {object} args + * @param {import('./types.js').PmNode} args.doc - Document to scan. + * @param {number} args.from - Range start position (inclusive). + * @param {number} args.to - Range end position (exclusive). + * @param {import('./types.js').MarkSnapshot} args.snapshot - Target snapshot. + * @returns {import('./types.js').PmMark | null} The matching live mark, + * or `null` if none found. + */ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { let exactMatch = null; let subsetMatch = null; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts deleted file mode 100644 index 07e56a0a2c..0000000000 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function trackedTransaction(...args: any[]): any; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 3b3af70cc1..5f1aadb789 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -308,9 +308,27 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { }; /** - * Tracked transaction to track changes. - * @param {{ tr: import('prosemirror-state').Transaction; state: import('prosemirror-state').EditorState; user: import('@core/types/EditorConfig.js').User; replacements?: 'paired' | 'independent' }} params - * @returns {import('prosemirror-state').Transaction} Modified transaction. + * Process a transaction through the track-changes pipeline and return + * a modified transaction with tracked-change marks applied (or the + * original transaction unchanged when track-changes is bypassed, e.g. + * for Yjs remote-origin transactions or disallowed meta). + * + * The per-property JSDoc style below is load-bearing: a single-blob + * `@param {{ ... }} params` form does not bind to the destructured + * arrow-function signature in vite-plugin-dts emit and resurfaces the + * function as `(...args: any[]): any` (SD-2980 PR C). + * + * @param {object} args + * @param {import('./types.js').Transaction} args.tr - The incoming + * transaction to process. + * @param {import('./types.js').EditorState} args.state - The editor + * state before `args.tr` is applied. + * @param {import('@core/types/EditorConfig.js').User} args.user - The + * acting user; required to attribute the tracked change. + * @param {'paired' | 'independent'} [args.replacements] - Strategy + * for processing replacement steps. Defaults to `'paired'`. + * @returns {import('./types.js').Transaction} The (possibly modified) + * transaction ready to dispatch. */ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) => { const onlyInputTypeMeta = ['inputType', 'uiEvent', 'paste', 'pointer', 'composition']; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js new file mode 100644 index 0000000000..05251a6b67 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js @@ -0,0 +1,54 @@ +/** + * Shared JSDoc typedefs for the `trackChangesHelpers` namespace. + * + * Defined once here so each helper file can reference them via + * `@typedef {import('./types.js').X}` without re-declaring (which + * would trigger ambiguous `export *` re-exports at the index barrel). + * + * NOT exported from `index.js`. Reference these types via JSDoc + * `import()` only; this module intentionally exports no runtime + * symbols. + * + * @typedef {import('prosemirror-model').Node} PmNode + * @typedef {import('prosemirror-model').Mark} PmMark + * @typedef {import('prosemirror-state').Transaction} Transaction + * @typedef {import('prosemirror-state').EditorState} EditorState + * + * @typedef {Record} Attrs + * + * @typedef {{ type: string; attrs: Attrs }} MarkSnapshot + * Compact mark descriptor used by the tracked-changes pipeline. + * `type` is the PM mark type name (e.g. `'bold'`); `attrs` is the + * snapshot's attribute bag, always present (`createMarkSnapshot` + * normalizes missing attrs to `{}`). + * + * @typedef {PmMark | SnapshotLike} MarkLike + * Helpers accept either a live ProseMirror mark or a snapshot-shaped + * object. Code reads `.type?.name ?? .type` to handle both: PM + * `Mark.type` is a `MarkType` object whose `.name` is the string, + * while `MarkSnapshot.type` is the string directly. The snapshot side + * uses the permissive `SnapshotLike` shape so helpers tolerate the + * loose `{ type?, attrs? }` typing that flows through attribute-bag + * channels (e.g. `formatChangeMark.attrs.before`). + * + * @typedef {{ node: PmNode; pos: number }} NodePosEntry + * The standard `findChildren` / `nodesBetween` result shape. + * + * @typedef {{ from: number; to: number; mark: PmMark }} TrackedMarkRange + * A live ProseMirror mark located in a `[from, to]` document range. + * Used as `findTrackedMarkBetween`'s non-null return and as the + * element shape of `getTrackChanges`'s result array. + * + * @typedef {{ type?: string; attrs?: Attrs }} SnapshotLike + * Permissive snapshot shape for helper inputs that flow through + * loosely-typed channels (e.g. `formatChangeMark.attrs.before`, + * which carries snapshots as a plain attribute bag). Helpers that + * accept `SnapshotLike[]` tolerate missing fields at runtime; + * `getTypeName` returns `undefined` for entries missing `type`, and + * attr-merge sites guard with `attrs || {}` / `{ ...existing.attrs }`. + * Helpers that PRODUCE snapshots still return strict `MarkSnapshot`. + */ + +// Module marker so TypeScript treats this as a module-scoped declaration +// file rather than a script. No runtime symbols are exported. +export {}; diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index 0979a94485..fcf98a5d4b 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -51,6 +51,7 @@ import { onCollaborationProviderSynced } from './core/helpers/collaboration-prov import { resolveSelectionTarget } from './document-api-adapters/helpers/selection-target-resolver.js'; import { resolveDefaultInsertTarget } from './document-api-adapters/helpers/adapter-utils.js'; import { resolveTrackedChangeInStory } from './document-api-adapters/helpers/tracked-change-resolver.js'; +import { syncCommentEntitiesFromCollaboration } from './document-api-adapters/helpers/comment-entity-store.js'; import { getTrackedChangeIndex } from './document-api-adapters/tracked-changes/tracked-change-index.js'; import { makeTrackedChangeAnchorKey, @@ -158,6 +159,8 @@ export { resolveDefaultInsertTarget, /** @internal */ resolveTrackedChangeInStory, + /** @internal SD-3214: feed collaboration-sourced comment metadata into the editor's CommentEntityStore. */ + syncCommentEntitiesFromCollaboration, // Story-aware tracked-change service /** @internal */ diff --git a/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js b/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js index a43c819d60..aaced23567 100644 --- a/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js +++ b/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js @@ -139,6 +139,49 @@ describe('Relationships tests', () => { expect(hasUnderline).toBe(false); }); + // PR-3209 regression tests for fixes. + it('preserves non-Hyperlink styleId on linked text after unlinking', () => { + editor.commands.insertContent('emphasized'); + editor.commands.selectAll(); + editor.commands.setMark('textStyle', { styleId: 'Emphasis' }); + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + editor.commands.unsetLink(); + + const styleIds = []; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === 'textStyle') styleIds.push(mark.attrs?.styleId ?? null); + }); + }); + expect(styleIds).toContain('Emphasis'); + }); + + it('renders link with underline even when underlying text has explicit underlineType=none', () => { + const underlineMarkType = editor.schema.marks.underline; + editor.commands.insertContent('mute'); + editor.commands.selectAll(); + + editor.commands.command(({ tr, dispatch }) => { + const { from, to } = editor.state.selection; + tr.addMark(from, to, underlineMarkType.create({ underlineType: 'none' })); + dispatch(tr); + return true; + }); + + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + + let visibleUnderline = null; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name !== 'underline') return; + if (mark.attrs?.underlineType !== 'none') visibleUnderline = mark; + }); + }); + expect(visibleUnderline).not.toBeNull(); + }); + it('keeps imported inline underline mark when removing link', async () => { const imported = await loadTestDataForEditorTests('hyperlink_node.docx'); const { editor: importedEditor } = initTestEditor({ 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/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts index 682565e733..3a1899c720 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/track-changes.ts @@ -6,17 +6,36 @@ import { resolveStateEditor } from './context.js'; import { isCommandDisabled } from './general.js'; import type { ToolbarContext } from '../types.js'; +// SD-3213f: prefer the narrow `superdoc.getComment(id)` method when +// present (SuperDoc instances and adopting host stubs). Fall back to +// the legacy `commentsStore.getComment(id)` reach for custom host stubs +// that pre-date the narrow method. +const lookupCommentByCommentId = ( + superdoc: Record | undefined, + commentId: string, +): Record | null => { + if (typeof superdoc?.getComment === 'function') { + return superdoc.getComment(commentId) ?? null; + } + const store = superdoc?.commentsStore; + if (typeof store?.getComment === 'function') { + return store.getComment(commentId) ?? null; + } + return null; +}; + const enrichTrackedChanges = (trackedChanges: Array> = [], superdoc?: Record) => { if (!trackedChanges.length) return trackedChanges; - const store = superdoc?.commentsStore; - if (!store?.getComment) return trackedChanges; return trackedChanges.map((change) => { const commentId = change.id; if (!commentId) return change; - const storeComment = store.getComment(commentId); + const storeComment = lookupCommentByCommentId(superdoc, commentId); if (!storeComment) return change; - const comment = typeof storeComment.getValues === 'function' ? storeComment.getValues() : storeComment; + const comment = + typeof (storeComment as { getValues?: () => unknown }).getValues === 'function' + ? (storeComment as { getValues: () => unknown }).getValues() + : storeComment; return { ...change, comment }; }); }; diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts index 9530e88a7e..af58f04af0 100644 --- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts +++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { resolveToolbarSources } from './resolve-toolbar-sources.js'; @@ -92,4 +92,93 @@ describe('resolveToolbarSources', () => { expect(result.context?.surface).toBe('note'); expect(result.context?.target.doc).toBe(noteEditor.doc); }); + + // SD-3213f: the resolver prefers the narrow + // `getPresentationEditorForDocument` host method when present, falling + // back to the legacy `superdocStore.documents[]` reach for custom host + // stubs that pre-date the narrow method. These two tests pin the + // dispatch logic so a future refactor cannot silently drop either + // branch. + it('uses the narrow getPresentationEditorForDocument host method when present', () => { + const bodyEditor = { + commands: { toggleBold: () => true }, + doc: { kind: 'body-doc' }, + isEditable: true, + state: { + selection: { + empty: false, + }, + }, + options: { + documentId: 'doc-narrow', + }, + }; + const presentationEditor = { + commands: { toggleBold: () => true }, + isEditable: true, + state: { + selection: { + empty: false, + }, + }, + getActiveEditor: () => bodyEditor, + }; + const getPresentationEditorForDocument = vi.fn(() => presentationEditor as any); + + const result = resolveToolbarSources({ + activeEditor: bodyEditor as any, + getPresentationEditorForDocument, + }); + + expect(getPresentationEditorForDocument).toHaveBeenCalledWith('doc-narrow'); + expect(result.presentationEditor).toBe(presentationEditor); + expect(result.activeEditor).toBe(bodyEditor); + }); + + it('prefers the narrow host method over the legacy superdocStore fallback when both are present', () => { + const bodyEditor = { + commands: { toggleBold: () => true }, + doc: { kind: 'body-doc' }, + isEditable: true, + state: { + selection: { + empty: false, + }, + }, + options: { + documentId: 'doc-precedence', + }, + }; + const narrowPresentationEditor = { + commands: { toggleBold: () => true }, + isEditable: true, + state: { selection: { empty: false } }, + getActiveEditor: () => bodyEditor, + }; + const legacyPresentationEditor = { + commands: { toggleBold: () => true }, + isEditable: true, + state: { selection: { empty: false } }, + getActiveEditor: () => bodyEditor, + }; + const getPresentationEditorForDocument = vi.fn(() => narrowPresentationEditor as any); + const legacyGetPresentationEditor = vi.fn(() => legacyPresentationEditor as any); + + const result = resolveToolbarSources({ + activeEditor: bodyEditor as any, + getPresentationEditorForDocument, + superdocStore: { + documents: [ + { + getEditor: () => bodyEditor as any, + getPresentationEditor: legacyGetPresentationEditor, + }, + ], + }, + }); + + expect(getPresentationEditorForDocument).toHaveBeenCalledWith('doc-precedence'); + expect(legacyGetPresentationEditor).not.toHaveBeenCalled(); + expect(result.presentationEditor).toBe(narrowPresentationEditor); + }); }); diff --git a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts index cb6c930d90..009e733359 100644 --- a/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts +++ b/packages/super-editor/src/headless-toolbar/resolve-toolbar-sources.ts @@ -47,15 +47,23 @@ type EditorWithPresentationOwner = Editor & { _presentationEditor?: PresentationEditor | null; }; -const resolvePresentationEditor = (superdoc: { +// SD-3213f: accept both the narrow SuperDoc method +// (`getPresentationEditorForDocument`) and the legacy `superdocStore` +// shape. The narrow method is preferred when present (SuperDoc +// instances and host stubs that adopt the new API). The legacy fallback +// keeps existing custom host stubs working without forcing a churn. +type ToolbarHostShape = { activeEditor?: Editor | null; + getPresentationEditorForDocument?: (documentId: string) => PresentationEditor | null; superdocStore?: { documents?: Array<{ getPresentationEditor?: () => PresentationEditor | null | undefined; getEditor?: () => Editor | null | undefined; }>; }; -}): PresentationEditor | null => { +}; + +const resolvePresentationEditor = (superdoc: ToolbarHostShape): PresentationEditor | null => { const activeEditor = (superdoc.activeEditor as EditorWithPresentationOwner | null | undefined) ?? null; const directPresentationEditor = activeEditor?.presentationEditor ?? activeEditor?._presentationEditor ?? null; if (directPresentationEditor) { @@ -65,21 +73,20 @@ const resolvePresentationEditor = (superdoc: { const documentId = activeEditor?.options?.documentId; if (!documentId) return null; - // Resolve the PresentationEditor for the same document as the current raw editor. + // Prefer the narrow public method (SD-3213f) when the host provides it. + if (typeof superdoc.getPresentationEditorForDocument === 'function') { + return superdoc.getPresentationEditorForDocument(documentId); + } + + // Legacy fallback: resolve the PresentationEditor for the same document + // as the current raw editor by walking `superdocStore.documents[]`. + // Kept for custom host stubs that pre-date the narrow method. const documents = superdoc.superdocStore?.documents ?? []; const matchedDoc = documents.find((doc) => doc.getEditor?.()?.options?.documentId === documentId); return matchedDoc?.getPresentationEditor?.() ?? null; }; -export const resolveToolbarSources = (superdoc: { - activeEditor?: Editor | null; - superdocStore?: { - documents?: Array<{ - getPresentationEditor?: () => PresentationEditor | null | undefined; - getEditor?: () => Editor | null | undefined; - }>; - }; -}): ResolvedToolbarSources => { +export const resolveToolbarSources = (superdoc: ToolbarHostShape): ResolvedToolbarSources => { const presentationEditor = resolvePresentationEditor(superdoc); if (presentationEditor) { diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 091d369761..281a91074b 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -967,6 +967,57 @@ describe('createToolbarRegistry', () => { ); }); + // SD-3213f: the tracked-change enricher prefers the narrow + // `superdoc.getComment(id)` method when present, falling back to + // `commentsStore.getComment(id)` for custom host stubs that pre-date + // the narrow method. Pin precedence so a future refactor cannot flip + // it silently. (The legacy branch above already covers the + // commentsStore path in isolation.) + it('prefers superdoc.getComment over commentsStore.getComment when both are present', () => { + collectTrackedChangesMock.mockReturnValueOnce([{ id: 'tc-narrow', attrs: {} }]); + isTrackedChangeActionAllowedMock.mockReturnValueOnce(true); + + const narrowGetComment = vi.fn(() => ({ id: 'tc-narrow', body: 'narrow-body' })); + const legacyGetComment = vi.fn(() => ({ + getValues: () => ({ id: 'tc-narrow', body: 'legacy-body' }), + })); + + const registry = createToolbarRegistry(); + registry['track-changes-accept-selection']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: {}, + selection: { + from: 1, + to: 3, + }, + }, + } as any, + }, + superdoc: { + getComment: narrowGetComment, + commentsStore: { + getComment: legacyGetComment, + }, + }, + }); + + expect(narrowGetComment).toHaveBeenCalledWith('tc-narrow'); + expect(legacyGetComment).not.toHaveBeenCalled(); + expect(isTrackedChangeActionAllowedMock).toHaveBeenCalledWith( + expect.objectContaining({ + trackedChanges: [ + expect.objectContaining({ + id: 'tc-narrow', + comment: { id: 'tc-narrow', body: 'narrow-body' }, + }), + ], + }), + ); + }); + it('derives document-mode value from superdoc config', () => { const registry = createToolbarRegistry(); const state = registry['document-mode']?.state({ diff --git a/packages/super-editor/src/headless-toolbar/types.ts b/packages/super-editor/src/headless-toolbar/types.ts index 25264a48ce..9ff2f6fe11 100644 --- a/packages/super-editor/src/headless-toolbar/types.ts +++ b/packages/super-editor/src/headless-toolbar/types.ts @@ -2,6 +2,25 @@ import type { Editor } from '../editors/v1/core/Editor.js'; import type { PresentationEditor } from '../editors/v1/core/presentation-editor/index.js'; import type { DocumentApi } from '@superdoc/document-api'; +/** + * Event names the headless toolbar host subscribes to. Narrow union + * so a real `SuperDoc` instance (with the SD-3213 closed + * `SuperDocEventMap`-typed `on`) satisfies the structural host + * contract. Custom host stubs typed with a wider + * `on?: (event: string, ...) => void` are still assignable. + * + * Split from the UI controller's narrower + * `SuperDocUIHostEvent` (`ui/types.ts`, 3 events) because the toolbar + * additionally subscribes to `formatting-marks-change`; requiring the + * UI controller's `SuperDocLike` stub to accept that 4th event would + * be wider than the UI side actually consumes. + */ +export type HeadlessToolbarSuperdocHostEvent = + | 'editorCreate' + | 'document-mode-change' + | 'formatting-marks-change' + | 'zoomChange'; + /** * The editable surface that currently owns the toolbar context. * @@ -177,8 +196,18 @@ export type ToolbarCommandState = { }; // Minimal execution surface for headless toolbar consumers. +// +// `commands` is the heterogeneous registry of editor commands documented +// as an escape hatch for direct access when `execute()` doesn't cover +// the use case (see headless-toolbar/README.md and apps/docs/advanced/ +// headless-toolbar.mdx). Each command has its own arg shape, so the +// index-signature value is `(...args: unknown[]) => unknown` instead +// of the previous `any[] => any`. This mirrors the established +// `AnyCommand` pattern used by `EditorCommands` (ChainedCommands.ts:31) +// and drains 3 SD-3213 supported-root any-leak findings. Consumers +// narrow at the call site for the specific command they're invoking. export type ToolbarTarget = { - commands: Record any>; + commands: Record unknown>; doc?: DocumentApi; }; @@ -247,7 +276,12 @@ export type HeadlessToolbarController = { */ export type ToolbarExecuteFn = (id: PublicToolbarItemId, payload?: unknown) => boolean; -export type HeadlessToolbarSuperdocHost = { +/** + * Common fields shared by every accepted `createHeadlessToolbar` host + * shape. Pulled out so the two host branches below stay aligned without + * duplication. + */ +type HeadlessToolbarSuperdocHostBase = { activeEditor?: Editor | null; config?: { layoutEngineOptions?: { @@ -255,8 +289,39 @@ export type HeadlessToolbarSuperdocHost = { }; }; toggleFormattingMarks?: () => void; - on?: (event: string, listener: (...args: any[]) => void) => void; - off?: (event: string, listener: (...args: any[]) => void) => void; + // The toolbar only subscribes to these SuperDoc events; keeping the + // host event names narrow lets strict event maps satisfy this + // structural host contract. See `HeadlessToolbarSuperdocHostEvent` above. + on?: (event: HeadlessToolbarSuperdocHostEvent, listener: (...args: any[]) => void) => void; + off?: (event: HeadlessToolbarSuperdocHostEvent, listener: (...args: any[]) => void) => void; +}; + +/** + * Narrow host shape introduced in SD-3213f. `SuperDoc` instances satisfy + * this branch directly: the two narrow methods replace the raw-store + * reach that `resolveToolbarSources` and `track-changes.ts` used before. + */ +type HeadlessToolbarSuperdocHostNarrow = HeadlessToolbarSuperdocHostBase & { + getPresentationEditorForDocument?: (documentId: string) => PresentationEditor | null; + getComment?: (commentId: string) => Record | null; +}; + +/** + * Legacy host shape kept for pre-SD-3213f typed custom host stubs that + * pass `superdocStore.documents[]` directly. The runtime still accepts + * this path; the type is retained so inline object-literal custom hosts + * compile without `any` casts. + * + * `commentsStore` was never advertised on this type pre-SD-3213f, so it + * is intentionally not added here even though `track-changes.ts` + * accepts the field at runtime. Adding it now would be public-surface + * growth, not backward-compat. + * + * @deprecated Prefer the narrow host methods on + * `HeadlessToolbarSuperdocHostNarrow` (SD-3213f). Will be removed in + * a future major after custom host stubs adopt the narrow methods. + */ +type HeadlessToolbarSuperdocHostLegacy = HeadlessToolbarSuperdocHostBase & { superdocStore?: { documents?: Array<{ getPresentationEditor?: () => PresentationEditor | null | undefined; @@ -265,6 +330,14 @@ export type HeadlessToolbarSuperdocHost = { }; }; +/** + * Host accepted by `createHeadlessToolbar({ superdoc })`. Union of the + * narrow SD-3213f shape (preferred; SuperDoc satisfies it) and the + * legacy `superdocStore` shape (deprecated; kept so inline custom host + * stubs from before SD-3213f keep compiling without `any` casts). + */ +export type HeadlessToolbarSuperdocHost = HeadlessToolbarSuperdocHostNarrow | HeadlessToolbarSuperdocHostLegacy; + export type CreateHeadlessToolbarOptions = { superdoc: HeadlessToolbarSuperdocHost; commands?: PublicToolbarItemId[]; diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 33844bd319..abbc9b3593 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -8,12 +8,15 @@ import type { ToolbarSnapshot, } from '../headless-toolbar/types.js'; import type { + AnchoredMetadataResolveInfo, CommentsListResult, ContentControlInfo, ContentControlsListResult, Receipt, ScrollIntoViewInput, ScrollIntoViewOutput, + SelectionTarget, + TextTarget, TrackChangesListResult, } from '@superdoc/document-api'; import { collectEntityHitsFromChain } from './entity-at.js'; @@ -38,6 +41,7 @@ import type { DocumentSlice, DynamicCommandHandle, EqualityFn, + MetadataHandle, TrackChangesHandle, TrackChangesItem, TrackChangesSlice, @@ -213,6 +217,36 @@ function deepFreeze(value: T): T { return Object.freeze(value); } +/** + * SD-3213g: single documented bridge between `SuperDocLike` and + * `resolveToolbarSources`'s `ToolbarHostShape`. The cast lives here so + * callers don't repeat `superdoc as never` at every site. + * + * Why the cast is intentional, not type-system noise: + * + * - `SuperDocLike` is intentionally stub-friendly. Its `activeEditor` + * is `SuperDocEditorLike | null` (a UI-level structural type) so + * consumer tests can pass narrow handcrafted hosts without pulling + * in the full `Editor` graph. + * - At runtime, a real `SuperDoc` instance's `activeEditor` is always + * a real `Editor` that satisfies `ToolbarHostShape` structurally. + * The two types describe the same runtime value at different + * abstraction levels. + * - Custom commands (and other UI paths) require **late-bound** routing + * resolved at execute time, not at controller construction; the + * cached `toolbarSnapshot.context` only reflects state at the last + * subscription event. So fresh `resolveToolbarSources` calls are + * load-bearing for the `'execute receives the routed editor + * late-bound'` contract pinned in `custom-commands.test.ts`. + * + * Use this helper anywhere the UI needs a fresh resolver walk. Do not + * call `resolveToolbarSources(superdoc as never)` elsewhere in this + * file. + */ +function resolveFreshToolbarSources(superdoc: SuperDocUIOptions['superdoc']) { + return resolveToolbarSources(superdoc as never); +} + /** * Resolve the **routed** editor โ€” the body, header, footer, or note * editor that PresentationEditor currently routes input/selection to. @@ -226,7 +260,7 @@ function deepFreeze(value: T): T { */ function resolveRoutedEditor(superdoc: SuperDocUIOptions['superdoc']): SuperDocEditorLike | null { try { - const sources = resolveToolbarSources(superdoc as never); + const sources = resolveFreshToolbarSources(superdoc); return (sources.activeEditor as unknown as SuperDocEditorLike | null) ?? null; } catch { return (superdoc.activeEditor ?? null) as SuperDocEditorLike | null; @@ -260,7 +294,7 @@ function resolvePresentationEditor(superdoc: SuperDocUIOptions['superdoc']): { off?: (event: string, handler: (...args: unknown[]) => void) => unknown; } | null { try { - const sources = resolveToolbarSources(superdoc as never); + const sources = resolveFreshToolbarSources(superdoc); return (sources.presentationEditor as never) ?? null; } catch { return null; @@ -335,7 +369,7 @@ function readActiveStoryLocator( ): import('@superdoc/document-api').StoryLocator | null { let presentation: { getActiveStoryLocator?: () => unknown } | null = null; try { - const sources = resolveToolbarSources(superdoc as never); + const sources = resolveFreshToolbarSources(superdoc); presentation = (sources.presentationEditor as never) ?? null; } catch { return null; @@ -394,6 +428,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // same selector substrate as the rest of the controller. Per-command // state derivers in the registry are wrapped to default to disabled // on throw, so a partial editor never wedges snapshot construction. + // SD-3213g: documented bridge cast. Same rationale as the comment on + // `resolveFreshToolbarSources` above: SuperDocLike is intentionally + // stub-friendly, runtime SuperDoc.activeEditor is a real Editor that + // satisfies the host contract structurally. Concentrated here so the + // call site stays an obvious boundary, not scattered casts elsewhere. const toolbarController: HeadlessToolbarController = createHeadlessToolbar({ superdoc: superdoc as unknown as HeadlessToolbarSuperdocHost, // Pass the full registry so snapshot.commands is populated for @@ -504,9 +543,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return; } try { - const result = list.call(editor.doc!.contentControls, undefined) as - | ContentControlsListResult - | undefined; + const result = list.call(editor.doc!.contentControls, undefined) as ContentControlsListResult | undefined; contentControlsListCache = result ?? EMPTY_CONTENT_CONTROLS_LIST; } catch { // See refreshCommentsListCache: prefer empty over leaking the @@ -575,8 +612,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const selectedNode = selection.node; if ( selectedNode && - (selectedNode.type?.name === 'structuredContent' || - selectedNode.type?.name === 'structuredContentBlock') + (selectedNode.type?.name === 'structuredContent' || selectedNode.type?.name === 'structuredContentBlock') ) { const id = selectedNode.attrs?.id; if (typeof id === 'string' && id.length > 0 && validIds.has(id)) { @@ -589,10 +625,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const anchor = selection.$anchor; if (anchor && typeof anchor.depth === 'number' && typeof anchor.node === 'function') { for (let d = anchor.depth; d >= 0; d -= 1) { - const node = anchor.node(d) as - | { type?: { name?: string }; attrs?: { id?: unknown } } - | null - | undefined; + const node = anchor.node(d) as { type?: { name?: string }; attrs?: { id?: unknown } } | null | undefined; const typeName = node?.type?.name; if (typeName !== 'structuredContent' && typeName !== 'structuredContentBlock') continue; const id = node?.attrs?.id; @@ -2217,6 +2250,107 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // Resolve a metadata id (= the SDT's w:tag) to the SDT's content- + // control id, reading from the same cached slice contentControls.get + // uses. The match is on `properties.tag`, which is the value passed + // to `editor.doc.metadata.attach` (or the auto-generated id when the + // caller omits one). Globally unique within a document โ€” attach + // rejects duplicate ids โ€” so the first match is the only match. + const findContentControlIdByMetadataId = (metadataId: string): string | null => { + for (const item of contentControlsListCache.items) { + if (item.properties?.tag === metadataId) return item.id; + } + return null; + }; + + // Convert a same-block or cross-block SelectionTarget into the + // TextTarget shape `ui.viewport.scrollIntoView` accepts. Returns + // null when the selection contains a `nodeEdge` endpoint, which has + // no clean TextTarget representation โ€” callers map that to a + // failure rather than guessing a fallback position. + const selectionTargetToTextTarget = (target: SelectionTarget): TextTarget | null => { + const { start, end } = target; + if (start.kind !== 'text' || end.kind !== 'text') return null; + if (start.blockId === end.blockId) { + return { + kind: 'text', + segments: [{ blockId: start.blockId, range: { start: start.offset, end: end.offset } }], + ...(target.story ? { story: target.story } : {}), + }; + } + // Cross-block: anchored-metadata v1 attaches over same-block text + // ranges only, so this branch is defensive. Represent as two + // collapsed segments at the start and end points; + // `scrollRangeIntoView` walks the segments in document order and + // scrolls to the first one, so the effect is "scroll to the start + // endpoint" โ€” accepted as the defensive fallback rather than + // approximating a bounding box across blocks. If a future metadata + // path produces a real cross-block anchor we should revisit this + // (likely by returning null and surfacing the failure to the caller). + return { + kind: 'text', + segments: [ + { blockId: start.blockId, range: { start: start.offset, end: start.offset } }, + { blockId: end.blockId, range: { start: end.offset, end: end.offset } }, + ], + ...(target.story ? { story: target.story } : {}), + }; + }; + + // Confirm `id` actually maps to a stored metadata payload before + // we trust the cc.items tagโ†’nodeId map. An imported DOCX can carry + // foreign inline content controls whose `w:tag` happens to match a + // metadata id; without this gate, a tag-only lookup would return + // the foreign control's geometry. The source path + // (`editor.doc.metadata.resolve`) was tightened to require both + // halves of the anchor (SDT + payload) to agree; this defensive + // gate keeps `ui.metadata.*` symmetrical for direct callers that + // skip `resolve`. + const hasMetadataPayload = (id: string): boolean => { + const editor = superdoc.activeEditor as SuperDocEditorLike | undefined; + const getFn = editor?.doc?.metadata?.get; + if (typeof getFn !== 'function') return false; + // `!= null` (not `!== null`) so a stub or adapter returning + // `undefined` for an unknown id is treated as absent โ€” production + // `metadata.get` returns `null`, but the structural type permits + // either and we want both paths to gate the same way. + return getFn.call(editor!.doc!.metadata!, { id }) != null; + }; + + const metadata: MetadataHandle = { + getRect({ id }: { id: string }) { + if (!id) return { success: false, reason: 'invalid-target' }; + if (!hasMetadataPayload(id)) return { success: false, reason: 'unresolved' }; + const ccId = findContentControlIdByMetadataId(id); + if (ccId === null) return { success: false, reason: 'unresolved' }; + return contentControls.getRect({ id: ccId }); + }, + async scrollIntoView({ + id, + block, + behavior, + }: { + id: string; + block?: ScrollIntoViewInput['block']; + behavior?: ScrollIntoViewInput['behavior']; + }): Promise { + if (!id) return { success: false }; + if (!hasMetadataPayload(id)) return { success: false }; + const editor = superdoc.activeEditor as SuperDocEditorLike | undefined; + const resolveFn = editor?.doc?.metadata?.resolve; + if (typeof resolveFn !== 'function') return { success: false }; + const info = resolveFn.call(editor!.doc!.metadata!, { id }) as AnchoredMetadataResolveInfo | null; + if (!info) return { success: false }; + const textTarget = selectionTargetToTextTarget(info.target); + if (!textTarget) return { success: false }; + return viewport.scrollIntoView({ + target: textTarget, + ...(block !== undefined ? { block } : {}), + ...(behavior !== undefined ? { behavior } : {}), + }); + }, + }; + const destroy = () => { if (destroyed) return; destroyed = true; @@ -2253,6 +2387,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { comments, trackChanges, contentControls, + metadata, selection, viewport, document, diff --git a/packages/super-editor/src/ui/entity-at.test.ts b/packages/super-editor/src/ui/entity-at.test.ts index d7cbc0b550..1e42b6cd19 100644 --- a/packages/super-editor/src/ui/entity-at.test.ts +++ b/packages/super-editor/src/ui/entity-at.test.ts @@ -120,17 +120,13 @@ describe('collectEntityHitsFromChain', () => { }); it('surfaces a contentControl hit for block structured-content wrappers', () => { - const inner = buildPaintedChain([ - { sdtId: 'sdt-2', sdtType: 'structuredContent', sdtScope: 'block' }, - ]); + const inner = buildPaintedChain([{ sdtId: 'sdt-2', sdtType: 'structuredContent', sdtScope: 'block' }]); expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'contentControl', id: 'sdt-2', scope: 'block' }]); }); it('does not surface non-structuredContent SDT types (fieldAnnotation, documentSection, docPartObject)', () => { - const fieldAnnotation = buildPaintedChain([ - { sdtId: 'fa-1', sdtType: 'fieldAnnotation' }, - ]); + const fieldAnnotation = buildPaintedChain([{ sdtId: 'fa-1', sdtType: 'fieldAnnotation' }]); expect(collectEntityHitsFromChain(fieldAnnotation)).toEqual([]); const section = buildPaintedChain([{ sdtId: 'sec-1', sdtType: 'documentSection' }]); @@ -186,9 +182,7 @@ describe('collectEntityHitsFromChain', () => { { sdtId: 'sdt-x', sdtType: 'structuredContent', sdtScope: 'inline' }, ]); - expect(collectEntityHitsFromChain(inner)).toEqual([ - { type: 'contentControl', id: 'sdt-x', scope: 'inline' }, - ]); + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'contentControl', id: 'sdt-x', scope: 'inline' }]); }); // ------------------------------------------------------------------------- diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index bec3c9ef03..68e778013e 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -127,6 +127,9 @@ export type { ContentControlsHandle, ContentControlsSlice, + // Anchored metadata (SD-3204) + MetadataHandle, + // Viewport ContentControlViewportAddress, ViewportContext, diff --git a/packages/super-editor/src/ui/metadata.test.ts b/packages/super-editor/src/ui/metadata.test.ts new file mode 100644 index 0000000000..62d2426c19 --- /dev/null +++ b/packages/super-editor/src/ui/metadata.test.ts @@ -0,0 +1,280 @@ +/** + * Focused tests for the `ui.metadata` handle (SD-3204). + * + * The handle's job is to hide the metadata-id โ†’ SDT node-id bridge + * that custom UI would otherwise compose from `useSuperDocContentControls` + * + a tagโ†’nodeId map + `ui.contentControls.getRect`. These tests + * exercise that bridge directly: + * + * - getRect({ id }) maps metadata id (= w:tag) to the SDT's content- + * control id and delegates to ui.viewport.getRect. + * - getRect with empty id returns invalid-target. + * - getRect with an id that has no matching cc.items entry returns + * unresolved (the bridge boundary the bot review on SD-3208 + * explicitly called out). + * - scrollIntoView({ id }) resolves metadata id โ†’ SelectionTarget, + * converts to TextTarget, and delegates to ui.viewport.scrollIntoView. + * - scrollIntoView with unknown id / nodeEdge endpoint returns + * { success: false } rather than scrolling to an approximation. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +type ContentControlItem = { + nodeType: 'sdt'; + kind: 'inline' | 'block'; + id: string; + controlType: string; + lockMode: string; + properties: Record; + target: { kind: 'inline' | 'block'; nodeType: 'sdt'; nodeId: string }; +}; + +function makeItem(ccId: string, metadataTag: string): ContentControlItem { + return { + nodeType: 'sdt', + kind: 'inline', + id: ccId, + controlType: 'richText', + lockMode: 'unlocked', + properties: { tag: metadataTag }, + target: { kind: 'inline', nodeType: 'sdt', nodeId: ccId }, + }; +} + +function makeStub(opts: { + items?: ContentControlItem[]; + /** + * Per-id metadata payloads. `null` means "no payload entry" (returned + * by `metadata.get` for ids that aren't anchored metadata, e.g. foreign + * inline SDTs imported from Word). Defaults to a present payload for + * every id mentioned in `resolveByMetadataId`, so existing scrollIntoView + * tests don't need to spell it out. + */ + payloadByMetadataId?: Record; + resolveByMetadataId?: Record< + string, + { + id: string; + target: { + kind: 'selection'; + start: { kind: 'text' | 'nodeEdge'; blockId?: string; offset?: number; [k: string]: unknown }; + end: { kind: 'text' | 'nodeEdge'; blockId?: string; offset?: number; [k: string]: unknown }; + }; + } | null + >; +}) { + const items = opts.items ?? []; + const resolveByMetadataId = opts.resolveByMetadataId ?? {}; + // Default payload map: every `properties.tag` in cc.items is treated as + // a real metadata anchor (i.e. `metadata.get` returns a non-null payload). + // The foreign-SDT test opts out by passing `payloadByMetadataId` with the + // id mapped to `null`, simulating an imported DOCX whose SDT carries that + // tag but has no customXml payload entry. + const payloadByMetadataId = + opts.payloadByMetadataId ?? + Object.fromEntries( + items + .map((item) => item.properties.tag) + .filter((tag): tag is string => typeof tag === 'string') + .map((tag) => [tag, { __seeded: true }]), + ); + + const editor = { + on: vi.fn(), + off: vi.fn(), + state: { + selection: { $anchor: { depth: 0, node: () => ({ type: { name: 'doc' } }) } }, + }, + doc: { + selection: { current: vi.fn(() => ({ empty: true, target: null })) }, + contentControls: { list: vi.fn(() => ({ items, total: items.length })) }, + metadata: { + get: vi.fn((input: { id: string }) => (input.id in payloadByMetadataId ? payloadByMetadataId[input.id] : null)), + resolve: vi.fn((input: { id: string }) => resolveByMetadataId[input.id] ?? null), + }, + }, + }; + + const superdoc: SuperDocLike = { + activeEditor: editor, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; + + return { superdoc, editor }; +} + +describe('ui.metadata.getRect (SD-3204)', () => { + it('maps metadata id (= w:tag) to cc node id and delegates to ui.viewport.getRect', () => { + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'getRect'); + ui.metadata.getRect({ id: 'meta-A' }); + + expect(spy).toHaveBeenCalledWith({ + target: { kind: 'entity', entityType: 'contentControl', entityId: 'sdt-7' }, + }); + + ui.destroy(); + }); + + it('returns invalid-target on empty id', () => { + const { superdoc } = makeStub({ items: [] }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'getRect'); + const result = ui.metadata.getRect({ id: '' }); + + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns unresolved when no cc.items entry has a matching properties.tag (bridge boundary)', () => { + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'getRect'); + const result = ui.metadata.getRect({ id: 'meta-Z' }); + + expect(result).toEqual({ success: false, reason: 'unresolved' }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns unresolved when an SDT has the matching tag but no metadata payload entry (foreign control)', () => { + // Imported DOCX with an inline SDT whose `w:tag === 'meta-A'` but no + // customXml payload โ€” could be a Word-authored content control that + // happens to share an id with a metadata anchor. The UI handle must + // not delegate to viewport.getRect for that control; if it did, + // citation highlights / popovers would land on an unrelated form + // field. The source-side fix to `editor.doc.metadata.resolve` plus + // this UI-layer payload gate keep both surfaces consistent. + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + payloadByMetadataId: { 'meta-A': null }, + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'getRect'); + const result = ui.metadata.getRect({ id: 'meta-A' }); + + expect(result).toEqual({ success: false, reason: 'unresolved' }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); +}); + +describe('ui.metadata.scrollIntoView (SD-3204)', () => { + it('resolves metadata id and delegates to ui.viewport.scrollIntoView with a same-block TextTarget', async () => { + const { superdoc, editor } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + resolveByMetadataId: { + 'meta-A': { + id: 'meta-A', + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 3 }, + end: { kind: 'text', blockId: 'p1', offset: 12 }, + }, + }, + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'scrollIntoView').mockResolvedValue({ success: true }); + const out = await ui.metadata.scrollIntoView({ id: 'meta-A', block: 'center', behavior: 'smooth' }); + + expect(editor.doc.metadata.resolve).toHaveBeenCalledWith({ id: 'meta-A' }); + expect(spy).toHaveBeenCalledWith({ + target: { + kind: 'text', + segments: [{ blockId: 'p1', range: { start: 3, end: 12 } }], + }, + block: 'center', + behavior: 'smooth', + }); + expect(out).toEqual({ success: true }); + + ui.destroy(); + }); + + it('returns { success: false } on unknown id without calling viewport.scrollIntoView', async () => { + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + resolveByMetadataId: { 'meta-A': null }, + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'scrollIntoView'); + const out = await ui.metadata.scrollIntoView({ id: 'meta-A' }); + + expect(out).toEqual({ success: false }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns { success: false } when the SDT tag has no metadata payload (foreign control, same bridge as getRect)', async () => { + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + payloadByMetadataId: { 'meta-A': null }, + resolveByMetadataId: { + 'meta-A': { + id: 'meta-A', + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'text', blockId: 'p1', offset: 5 }, + }, + }, + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'scrollIntoView'); + const out = await ui.metadata.scrollIntoView({ id: 'meta-A' }); + + expect(out).toEqual({ success: false }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns { success: false } when the SelectionTarget endpoint is a nodeEdge (no clean TextTarget shape)', async () => { + const { superdoc } = makeStub({ + items: [makeItem('sdt-7', 'meta-A')], + resolveByMetadataId: { + 'meta-A': { + id: 'meta-A', + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: 0 }, + end: { kind: 'nodeEdge' }, + }, + }, + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const spy = vi.spyOn(ui.viewport, 'scrollIntoView'); + const out = await ui.metadata.scrollIntoView({ id: 'meta-A' }); + + expect(out).toEqual({ success: false }); + expect(spy).not.toHaveBeenCalled(); + + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/react/hooks.ts b/packages/super-editor/src/ui/react/hooks.ts index e0df1060e9..7d69ef7818 100644 --- a/packages/super-editor/src/ui/react/hooks.ts +++ b/packages/super-editor/src/ui/react/hooks.ts @@ -69,10 +69,7 @@ export function useSuperDocTrackChanges(): TrackChangesSlice { * ``` */ export function useSuperDocContentControls(): ContentControlsSlice { - return useSuperDocSlice( - (ui) => ui.select((state) => state.contentControls, shallowEqual), - EMPTY_CONTENT_CONTROLS, - ); + return useSuperDocSlice((ui) => ui.select((state) => state.contentControls, shallowEqual), EMPTY_CONTENT_CONTROLS); } /** Subscribe to the full toolbar snapshot (context + per-command states). */ diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 9c23b2d082..aafaada319 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -35,13 +35,22 @@ export interface Subscribable { } /** - * Structural typing for the SuperDoc instance โ€” keeps the UI controller + * Event names the UI controller (`createSuperDocUI`) subscribes to on + * a SuperDoc-like host. Narrower than + * `HeadlessToolbarSuperdocHostEvent` (which adds + * `formatting-marks-change`); a custom UI host stub only has to + * support the three events the UI controller actually consumes. + */ +export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange'; + +/** + * Structural typing for the SuperDoc instance. Keeps the UI controller * loose from the SuperDoc Vue package's specific class type. The * controller only needs an event bus and an `activeEditor` reference. */ export interface SuperDocLike { - on?(event: string, handler: (...args: unknown[]) => void): unknown; - off?(event: string, handler: (...args: unknown[]) => void): unknown; + on?(event: SuperDocUIHostEvent, handler: (...args: unknown[]) => void): unknown; + off?(event: SuperDocUIHostEvent, handler: (...args: unknown[]) => void): unknown; activeEditor?: SuperDocEditorLike | null; config?: { documentMode?: 'editing' | 'suggesting' | 'viewing' }; /** @@ -121,6 +130,22 @@ export interface SuperDocEditorLike { contentControls?: { list?(query?: unknown): unknown; }; + /** + * Anchored-metadata member on the Document API. Used by + * `ui.metadata.*` to look up an entry's resolved range from its + * id, and to verify (via `get`) that the id actually maps to a + * stored payload before delegating to the SDT-keyed geometry + * path โ€” a w:tag on its own can come from a Word-authored + * content control with no metadata payload, so the payload side + * has to agree. Structurally typed loose for the same + * stub-friendly reason as `comments` / `trackChanges` / + * `contentControls`; the controller asserts the concrete shapes + * after calling. + */ + metadata?: { + get?(input: { id: string }): unknown | null; + resolve?(input: { id: string }): unknown | null; + }; /** * Insert content at a positional target. Surfaces the typed * doc-API signature so custom commands can call @@ -465,6 +490,60 @@ export interface ContentControlsHandle { getRect(input: { id: string }): ViewportRectResult; } +/** + * Anchored-metadata domain handle exposed on `ui.metadata`. Sugar over + * the metadata-id โ†’ content-control-id โ†’ painter geometry bridge that + * custom UI would otherwise compose by hand: callers carry only the + * metadata id (the value they passed to `editor.doc.metadata.attach`) + * and never see the SDT node id underneath. + * + * Read / scroll only โ€” there are no mutation methods in v1. All + * mutations (`attach` / `update` / `remove`) stay on + * `editor.doc.metadata.*`; this handle is a UI surface, not a parallel + * mutation contract. + * + * No `namespace` parameter: `editor.doc.metadata.attach` enforces + * globally unique ids within a document (collisions fail with + * `INVALID_INPUT`), so the id is sufficient to identify an entry. + * No `getRects` either โ€” `getRect`'s success variant already exposes + * the per-line `rects[]` array; a second method with the same return + * shape would just add API noise. + */ +export interface MetadataHandle { + /** + * Painter rect for the anchor identified by metadata `id`. Internally + * resolves metadata-id โ†’ SDT node-id via the cached content-controls + * slice and delegates to {@link ContentControlsHandle.getRect}, so + * the success shape and failure reasons match the rest of the + * `ui.*.getRect` family exactly. + * + * Failure mapping: + * - empty id โ†’ `'invalid-target'` + * - unknown id in current document โ†’ `'unresolved'` + * - SDT exists but not painted (virtualized / pre-paint) โ†’ the + * reason from `contentControls.getRect` (typically + * `'not-mounted'` or `'not-ready'`) is propagated as-is. + */ + getRect(input: { id: string }): ViewportRectResult; + /** + * Scroll the viewport to the anchored span identified by metadata + * `id`. Internally calls `editor.doc.metadata.resolve` to get a + * `SelectionTarget`, converts it to a `TextTarget` (the shape + * `ui.viewport.scrollIntoView` accepts), and forwards + * `block`/`behavior` unchanged. Returns the same + * `ScrollIntoViewOutput` shape as `ui.viewport.scrollIntoView`; + * unknown ids, `nodeEdge` endpoints, and other shapes that can't be + * cleanly represented as a `TextTarget` resolve to + * `{ success: false }` rather than silently scrolling to an + * approximation. + */ + scrollIntoView(input: { + id: string; + block?: import('@superdoc/document-api').ScrollIntoViewInput['block']; + behavior?: import('@superdoc/document-api').ScrollIntoViewInput['behavior']; + }): Promise; +} + export interface CommentsSlice { /** Total count from the list result (before pagination, if any). */ total: number; @@ -577,6 +656,16 @@ export interface SuperDocUI { */ contentControls: ContentControlsHandle; + /** + * Anchored-metadata domain โ€” read + scroll surface keyed on the + * metadata id (= the value passed to `editor.doc.metadata.attach`). + * Hides the metadata-id โ†’ SDT-node-id bridge so custom UI doesn't + * have to compose `useSuperDocContentControls` + a tag โ†’ nodeId map + * + `ui.contentControls.getRect` itself. v1 has no mutation methods + * โ€” `editor.doc.metadata.*` is the mutation contract. + */ + metadata: MetadataHandle; + /** * Selection domain โ€” single subscription + read surface for * floating bubble menus, format toolbars, mention popovers, and @@ -1644,9 +1733,7 @@ export type ContentControlViewportAddress = { * Document API's `EntityAddress` (comment / tracked change) with the * UI-local content-control address. */ -export type ViewportEntityAddress = - | import('@superdoc/document-api').EntityAddress - | ContentControlViewportAddress; +export type ViewportEntityAddress = import('@superdoc/document-api').EntityAddress | ContentControlViewportAddress; export interface ViewportGetRectInput { /** diff --git a/packages/superdoc/AGENTS.md b/packages/superdoc/AGENTS.md index 4009880891..46498dd4c4 100644 --- a/packages/superdoc/AGENTS.md +++ b/packages/superdoc/AGENTS.md @@ -238,6 +238,28 @@ superdoc.on('editorCreate', ({ editor }) => { For backend or AI agent workflows, use the [SDK](https://docs.superdoc.dev/document-engine/sdks), [CLI](https://docs.superdoc.dev/document-engine/cli), or [MCP server](https://docs.superdoc.dev/document-engine/ai-agents/mcp-server) instead of browser editor access. +## Contributing to the public surface + +If you're adding, removing, or moving a name on the `superdoc` root entry, the source of truth is `packages/superdoc/src/public/index.ts`. It is organized in three tiers per SD-3212: + +1. **Supported root** โ€” documented public API; first-class root surface. Clean re-exports. +2. **Legacy root** โ€” typed for backward compatibility; not the recommended path. Per-name `@deprecated` JSDoc only where a real replacement exists (e.g. `editor.commands.*` โ†’ `editor.doc.*` Document API). Section-level framing carries "legacy compat" intent when no replacement exists. +3. **Internal candidate** โ€” accidental implementation leak. Kept typed under compat re-export with `@internal` so a future major can remove it. Only exists at root because at least one supported/legacy export reaches it transitively. + +When changing the surface, every PR must also update: + +- `tests/consumer-typecheck/snapshots/superdoc-root-classification.json` (bucket + rationale per name). +- `tests/consumer-typecheck/snapshots/superdoc-root-exports.json` (the 4-source no-growth lock). +- `packages/superdoc/scripts/verify-public-facade-emit.cjs` `FACADE_ENTRIES['root (./index)'].expectedNames`. + +Three CI gates enforce consistency and will fail the build if any of these drift: + +- `verify-public-facade-emit.cjs` โ€” symbol set, ESM/CJS parity, leak grep. +- `snapshot.mjs --family root --check` โ€” 4-source no-growth lock for the root entry. CI calls `snapshot.mjs --all --check`, which also runs `legacy` and `super-editor-package`. +- `check-root-classification-closure.mjs` โ€” no supported/legacy export references an internal-candidate type (dependency-closure rule). + +For overrides on the closure gate (rare; only DOM globals / upstream / generic utility types), add an entry to `OVERRIDES` in `check-root-classification-closure.mjs` with a reason string โ‰ฅ 20 chars. + ## Knowledge corrections If your training data mentions SuperDoc, note these updates: diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index fed2e198d1..260e86ae97 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -20,8 +20,8 @@ "exports": { ".": { "types": { - "import": "./dist/superdoc/src/index.d.ts", - "require": "./dist/superdoc/src/index.d.cts" + "import": "./dist/superdoc/src/public/index.d.ts", + "require": "./dist/superdoc/src/public/index.d.cts" }, "source": "./src/index.js", "import": "./dist/superdoc.es.js", @@ -84,7 +84,7 @@ }, "./style.css": "./dist/style.css" }, - "types": "./dist/superdoc/src/index.d.ts", + "types": "./dist/superdoc/src/public/index.d.ts", "typesVersions": { "*": { "headless-toolbar": [ diff --git a/packages/superdoc/scripts/ensure-types.cjs b/packages/superdoc/scripts/ensure-types.cjs index 8dc51c79b4..cab2a574a8 100644 --- a/packages/superdoc/scripts/ensure-types.cjs +++ b/packages/superdoc/scripts/ensure-types.cjs @@ -250,39 +250,46 @@ if (!hasSuperDocExport) { process.exit(1); } -// @superdoc/common is a private workspace package, so consumers can't -// resolve a bare `from '@superdoc/common'` import. The main entry -// (superdoc/src/index.d.ts) imports runtime values from it โ€” DOCX/PDF/ -// HTML constants, getFileObject, compareVersions, BlankDOCX (the last -// from a Vite `?url` import that vite-plugin-dts can't type). Strip -// that import statement and inline ambient declarations for those -// values. Type-only imports of @superdoc/common from other dist files -// are handled separately by the RELOCATION_RULES rewriter below, which +// @superdoc/common is a private workspace package, so consumers can't resolve +// bare or deep `@superdoc/common` imports from emitted declarations. The root +// entry and the path-as-contract public facade both expose a small set of +// runtime values from common (DOCX/PDF/HTML constants, getFileObject, +// compareVersions, BlankDOCX). Strip those imports and inline declarations for +// the exported names. Type-only imports of @superdoc/common from other dist +// files are handled separately by the RELOCATION_RULES rewriter below, which // maps bare @superdoc/common to dist/shared/common/comments-types.d.ts. -const hadWorkspaceImport = content.includes('@superdoc/common'); -if (hadWorkspaceImport) { - // Replace the @superdoc/common import with inline declarations - content = content.replace( - /import\s*\{[^}]*\}\s*from\s*['"]@superdoc\/common['"];?\s*\n?/g, - '', - ); +function inlineCommonRuntimeDeclarations(filePath) { + let fileContent = fs.readFileSync(filePath, 'utf8'); + if (!fileContent.includes('@superdoc/common')) return false; + + fileContent = fileContent + .replace(/import\s*\{[^}]*\}\s*from\s*['"]@superdoc\/common['"];?\s*\n?/g, '') + .replace(/import\s*\{[^}]*\}\s*from\s*['"]@superdoc\/common\/document-types['"];?\s*\n?/g, '') + .replace(/import\s*\{[^}]*\}\s*from\s*['"]@superdoc\/common\/helpers\/get-file-object['"];?\s*\n?/g, '') + .replace(/import\s*\{[^}]*\}\s*from\s*['"]@superdoc\/common\/helpers\/compare-superdoc-versions['"];?\s*\n?/g, ''); + + const hasExportedBlankDocxDeclaration = /\bexport\s+declare\s+const\s+BlankDOCX\b/.test(fileContent); + const declarations = [ + fileContent.includes('DOCX') && "declare const DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';", + fileContent.includes('PDF') && "declare const PDF: 'application/pdf';", + fileContent.includes('HTML') && "declare const HTML: 'text/html';", + fileContent.includes('getFileObject') && 'declare function getFileObject(fileUrl: string, name: string, type: string): Promise;', + fileContent.includes('compareVersions') && 'declare function compareVersions(version1: string, version2: string): -1 | 0 | 1;', + fileContent.includes('BlankDOCX') && !hasExportedBlankDocxDeclaration && '/** URL to the blank DOCX template */', + fileContent.includes('BlankDOCX') && !hasExportedBlankDocxDeclaration && 'declare const BlankDOCX: string;', + ].filter(Boolean); + + fs.writeFileSync(filePath, `${declarations.join('\n')}\n${fileContent}`); + return true; +} - // BlankDOCX comes from a Vite ?url import (resolves to a string at runtime) - // Declare it since vite-plugin-dts can't generate types for ?url imports - const inlineDeclarations = [ - '/** Document MIME type constants */', - "declare const DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';", - "declare const PDF: 'application/pdf';", - "declare const HTML: 'text/html';", - 'declare function getFileObject(fileUrl: string, name: string, type: string): Promise;', - 'declare function compareVersions(version1: string, version2: string): -1 | 0 | 1;', - '/** URL to the blank DOCX template */', - 'declare const BlankDOCX: string;', - ].join('\n'); - - content = inlineDeclarations + '\n' + content; - fs.writeFileSync(indexPath, content); - console.log('[ensure-types] โœ“ Inlined @superdoc/common types'); +const commonInlineTargets = [ + path.join(distRoot, 'superdoc/src/index.d.ts'), + path.join(distRoot, 'superdoc/src/public/index.d.ts'), +]; +const inlinedCommonTargets = commonInlineTargets.filter((target) => fs.existsSync(target) && inlineCommonRuntimeDeclarations(target)); +if (inlinedCommonTargets.length) { + console.log(`[ensure-types] โœ“ Inlined @superdoc/common runtime declarations in ${inlinedCommonTargets.length} entry point(s)`); } // --------------------------------------------------------------------------- diff --git a/packages/superdoc/scripts/type-surface.config.cjs b/packages/superdoc/scripts/type-surface.config.cjs index 791b8c7610..9deba19a4d 100644 --- a/packages/superdoc/scripts/type-surface.config.cjs +++ b/packages/superdoc/scripts/type-surface.config.cjs @@ -35,11 +35,18 @@ * - `rule1Allowlist`: bare `@superdoc/*` specifiers permitted in * published d.ts. Currently only the legacy public super-editor * surface per RFC Decision 1. + * - `publicContract`: SD-3256 Phase 2. Tier metadata for every + * `package.json#exports` subpath. Describes what each subpath is + * (supported / legacy / asset / deprecated), not yet enforced. + * `scripts/report-public-contract.mjs` prints this for review. * * Adding a new relocation: append one entry to `relocations` with the * package specifier, the dist target the rewriter should point at, and * the source-include patterns vite + tsconfig need. Every consumer picks * up the new entry without further edits. + * + * Adding a new public subpath: append an entry to `publicContract` with + * the correct tier. Keep it in sync with `package.json#exports`. */ const requiredEntryPoints = [ @@ -226,6 +233,65 @@ const rule1Allowlist = { '@superdoc/super-editor': 'legacy public surface (RFC Decision 1)', }; +/** + * SD-3256 Phase 2: tier metadata for every `package.json#exports` + * subpath. Describes what each entry is, not what CI enforces. No + * enforcement is wired up in this phase; the metadata exists so the + * team can review the classification before Phase 3 (./super-editor + * facade curation) and Phase 4 (ratchet against the tiers). + * + * Tier policies (target end state, not all enforced today): + * + * - `supported`: fully typed, no `any`, no accidental internals; + * supported-root strict gate hard-fails regressions. Routes + * through `src/public/**`. + * - `legacy`: must not grow accidentally; typed where supported; + * can be deprecated or migrated over time; new APIs should not + * be added here. Routes through `src/public/legacy/**`. + * - `legacy-raw`: legacy public surface that does NOT yet route + * through `src/public/legacy/**` (the export resolves directly + * to a non-curated dist path). Only `./super-editor` today. + * SD-3256 Phase 3 will curate this through + * `src/public/legacy/super-editor.ts` after team alignment on + * which exports stay public. + * - `asset`: non-type asset (e.g. CSS). Not covered by the type + * contract. + * - `deprecated`: scheduled for removal. None today. + * + * The `internal` tier is implicit: anything not exported here is + * internal and not part of the consumer promise. + * + * Sync rule: keep this list aligned with `package.json#exports`. + * Adding a new export means adding an entry here too. + */ +const publicContract = { + supported: [ + { subpath: '.', tier: 'supported', note: 'root facade; routes through src/public/index.ts' }, + { subpath: './types', tier: 'supported', note: 'type-only facade; src/public/types.ts' }, + { subpath: './ui', tier: 'supported', note: 'UI primitives; src/public/ui.ts' }, + { subpath: './ui/react', tier: 'supported', note: 'React adapter; src/public/ui-react.ts' }, + ], + legacy: [ + { subpath: './converter', tier: 'legacy', note: 'src/public/legacy/converter.ts' }, + { subpath: './docx-zipper', tier: 'legacy', note: 'src/public/legacy/docx-zipper.ts' }, + { subpath: './file-zipper', tier: 'legacy', note: 'src/public/legacy/file-zipper.ts' }, + { subpath: './headless-toolbar', tier: 'legacy', note: 'src/public/legacy/headless-toolbar.ts' }, + { subpath: './headless-toolbar/react', tier: 'legacy', note: 'src/public/legacy/headless-toolbar-react.ts' }, + { subpath: './headless-toolbar/vue', tier: 'legacy', note: 'src/public/legacy/headless-toolbar-vue.ts' }, + ], + legacyRaw: [ + { + subpath: './super-editor', + tier: 'legacy-raw', + note: 'resolves to dist/superdoc/src/super-editor.d.ts (not src/public/legacy/). SD-3256 Phase 3 will curate.', + }, + ], + asset: [ + { subpath: './style.css', tier: 'asset', note: 'CSS bundle; no types' }, + ], + deprecated: [], +}; + module.exports = { requiredEntryPoints, handwrittenDtsBlocklist, @@ -235,4 +301,5 @@ module.exports = { relocationGuardPackages, unshimmedPrivateSpecifiers, rule1Allowlist, + publicContract, }; diff --git a/packages/superdoc/scripts/verify-public-facade-emit.cjs b/packages/superdoc/scripts/verify-public-facade-emit.cjs index 5f6f8bae55..6bd32b0a70 100644 --- a/packages/superdoc/scripts/verify-public-facade-emit.cjs +++ b/packages/superdoc/scripts/verify-public-facade-emit.cjs @@ -77,52 +77,223 @@ try { // this gate. Link the PR to SD-3175 (path-as-contract umbrella) for // reviewer sign-off when growth is intentional. const FACADE_ENTRIES = [ - // SD-3178 + SD-3185: root facade. The supported programmatic surface - // is the Document API (`editor.doc.*`); see `packages/superdoc/AGENTS.md` - // and the @deprecated tags on `editor.commands` in `Editor.ts`. The - // 20 Document API types preserved here mirror the JSDoc @typedef block - // in `packages/superdoc/src/index.js` and the assertions in - // `tests/consumer-typecheck/src/all-public-types.ts` โ€” they were already - // typed via the root entry; this just makes the path-as-contract - // version explicit. - // - // EditorCommands stays in the surface for backward compatibility but is - // tagged @deprecated at the re-export site. The command-signature probe - // continues to run on this entry: it is now a *legacy command-signature - // compatibility check* (catches SD-2965-style augmentation drops on the - // deprecated surface) rather than a guarantee about supported API. + // SD-3212: root facade re-curated from the classification artifact. + // expectedNames intentionally mirrors + // tests/consumer-typecheck/snapshots/superdoc-root-classification.json. + // The root entry keeps supported public API, legacy public compatibility, + // and internal-candidate compat names typed until a major-version cleanup. + // The command-signature probe continues to run on this entry: it is a + // *legacy command-signature compatibility check* (catches SD-2965-style + // augmentation drops on the deprecated surface) rather than a guarantee + // about supported API. { name: 'root (./index)', esm: path.join(PUBLIC_DIST, 'index.d.ts'), cjs: path.join(PUBLIC_DIST, 'index.d.cts'), expectedNames: [ + 'AIWriter', + 'AnnotatorHelpers', + 'assertNodeType', + 'AwarenessState', + 'BinaryData', + 'BlankDOCX', 'BlockNavigationAddress', 'BlocksListResult', 'BookmarkAddress', 'BookmarkInfo', + 'BoundingRect', + 'buildTheme', + 'CanObject', + 'ChainableCommandObject', + 'ChainedCommand', + 'CollaborationConfig', + 'CollaborationProvider', + 'Command', + 'CommandProps', + 'Comment', 'CommentAddress', + 'CommentConfig', + 'CommentElement', + 'CommentLocationsPayload', + 'CommentsPayload', + 'CommentsPluginKey', + 'CommentsType', + 'compareVersions', 'Config', + 'ContextMenu', + 'ContextMenuConfig', + 'ContextMenuContext', + 'ContextMenuItem', + 'ContextMenuSection', + 'CoreCommandMap', + 'createTheme', + 'createZip', + 'defineMark', + 'defineNode', + 'DirectSurfaceRequest', + 'DocRange', 'DocumentApi', + 'DocumentMode', 'DocumentProtectionState', + 'DOCX', + 'DocxFileEntry', + 'DocxZipper', 'Editor', 'EditorCommands', + 'EditorEventMap', + 'EditorExtension', + 'EditorLifecycleState', + 'EditorOptions', + 'EditorState', + 'EditorSurface', + 'EditorTransactionEvent', + 'EditorUpdateEvent', + 'EditorView', 'EntityAddress', + 'ExportDocxParams', + 'ExportFormat', + 'ExportOptions', + 'ExportParams', + 'ExportType', + 'ExtensionCommandMap', + 'Extensions', + 'ExternalPopoverRenderContext', + 'ExternalSurfaceRenderContext', + 'fieldAnnotationHelpers', + 'FieldValue', + 'FindReplaceConfig', + 'FindReplaceContext', + 'FindReplaceHandle', + 'FindReplaceRenderContext', + 'FindReplaceResolution', + 'FlowBlock', + 'FlowMode', + 'FontConfig', + 'FontsResolvedPayload', + 'getActiveFormatting', + 'getAllowedImageDimensions', + 'getFileObject', + 'getMarksFromSelection', + 'getRichTextExtensions', + 'getSchemaIntrospection', + 'getStarterExtensions', + 'HTML', + 'ImageDeselectedEvent', + 'ImageSelectedEvent', + 'IntentSurfaceRequest', + 'isMarkType', + 'isNodeType', + 'Layout', + 'LayoutEngineOptions', + 'LayoutError', + 'LayoutFragment', + 'LayoutMetrics', + 'LayoutMode', + 'LayoutPage', + 'LayoutState', + 'LayoutUpdatePayload', + 'LinkPopoverContext', + 'LinkPopoverResolution', + 'LinkPopoverResolver', + 'ListDefinitionsPayload', + 'Measure', + 'Modules', 'NavigableAddress', + 'OpenOptions', + 'PageMargins', + 'PageSize', + 'PageStyles', + 'PaginationPayload', + 'PaintSnapshot', + 'PartChangedEvent', + 'PartId', + 'PartSectionId', + 'PasswordPromptAttemptResult', + 'PasswordPromptConfig', + 'PasswordPromptContext', + 'PasswordPromptHandle', + 'PasswordPromptRenderContext', + 'PasswordPromptResolution', + 'PDF', + 'PermissionParams', + 'PositionHit', + 'PresenceOptions', + 'PresentationEditor', + 'PresentationEditorOptions', + 'ProofingCapabilities', + 'ProofingCheckRequest', + 'ProofingCheckResult', + 'ProofingConfig', + 'ProofingError', + 'ProofingIssue', + 'ProofingIssueKind', + 'ProofingProvider', + 'ProofingSegment', + 'ProofingSegmentMetadata', + 'ProofingStatus', + 'ProseMirrorJSON', + 'ProtectionChangeSource', + 'RangeRect', + 'registeredHandlers', + 'RemoteCursorsRenderPayload', + 'RemoteCursorState', + 'RemoteUserInfo', + 'ResolvedFindReplaceTexts', + 'ResolvedPasswordPromptTexts', 'ResolveRangeOutput', + 'SaveOptions', + 'Schema', 'ScrollIntoViewInput', 'ScrollIntoViewOutput', + 'SearchMatch', + 'SectionHelpers', + 'SectionMetadata', 'SelectionApi', + 'SelectionCommandContext', 'SelectionCurrentInput', + 'SelectionHandle', 'SelectionInfo', + 'SlashMenu', 'StoryLocator', + 'SuperConverter', 'SuperDoc', + 'SuperDocLayoutEngineOptions', + 'SuperDocTelemetryConfig', + 'SuperEditor', + 'superEditorHelpers', + 'SuperInput', + 'SuperToolbar', + 'SurfaceComponentProps', + 'SurfaceFloatingPlacement', + 'SurfaceHandle', + 'SurfaceMode', + 'SurfaceOutcome', + 'SurfaceRequest', + 'SurfaceResolution', + 'SurfaceResolver', + 'SurfacesModuleConfig', + 'TelemetryEvent', 'TextAddress', 'TextSegment', 'TextTarget', + 'Toolbar', + 'TrackChangesBasePluginKey', + 'trackChangesHelpers', + 'TrackChangesModuleConfig', 'TrackedChangeAddress', + 'TrackedChangesMode', + 'TrackedChangesOverrides', + 'Transaction', + 'UnsupportedContentItem', + 'UpgradeToCollaborationOptions', + 'User', + 'ViewingVisibilityConfig', + 'ViewLayout', + 'ViewOptions', + 'VirtualizationOptions', ], runsCommandSignatureProbe: true, - ticket: 'SD-3185', + ticket: 'SD-3212', }, { name: 'legacy/headless-toolbar', @@ -268,6 +439,7 @@ const FACADE_ENTRIES = [ 'DynamicCommandHandle', 'EntityAddress', 'EqualityFn', + 'MetadataHandle', 'Receipt', 'ScrollIntoViewInput', 'ScrollIntoViewOutput', diff --git a/packages/superdoc/src/core/EventEmitter.test.ts b/packages/superdoc/src/core/EventEmitter.test.ts new file mode 100644 index 0000000000..7167e338cf --- /dev/null +++ b/packages/superdoc/src/core/EventEmitter.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EventEmitter, type DefaultEventMap } from './EventEmitter'; + +/** + * Focused tests for the SD-3213 EventEmitter drain. Both `on()` branches + * need coverage because the SD-3213 internal cast `as EventCallback` was + * added there (the storage `Map` + * default-erases the per-event tuple). Whiteboard only subscribes one + * listener per event in practice, so the multi-listener path was + * previously uncovered. + */ +interface TestEventMap extends DefaultEventMap { + ping: [{ id: string }]; + empty: []; +} + +describe('EventEmitter (SD-3213 drain)', () => { + describe('on()', () => { + it('registers the first listener via the "set" branch', () => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('ping', listener); + emitter.emit('ping', { id: '1' }); + + expect(listener).toHaveBeenCalledWith({ id: '1' }); + }); + + it('appends the Nth listener via the "push" branch', () => { + const emitter = new EventEmitter(); + const first = vi.fn(); + const second = vi.fn(); + const third = vi.fn(); + + // Same event name three times โ€” first goes through the `set` branch, + // second and third go through the `if (callbacks) callbacks.push(...)` + // branch where the SD-3213 internal cast lives. + emitter.on('ping', first); + emitter.on('ping', second); + emitter.on('ping', third); + + emitter.emit('ping', { id: '42' }); + + expect(first).toHaveBeenCalledWith({ id: '42' }); + expect(second).toHaveBeenCalledWith({ id: '42' }); + expect(third).toHaveBeenCalledWith({ id: '42' }); + }); + }); + + describe('emit / off / once', () => { + it('is a no-op when emitting an event with no subscribers', () => { + const emitter = new EventEmitter(); + expect(() => emitter.emit('ping', { id: 'x' })).not.toThrow(); + }); + + it('removes a specific listener with off(name, fn)', () => { + const emitter = new EventEmitter(); + const keep = vi.fn(); + const drop = vi.fn(); + emitter.on('ping', keep); + emitter.on('ping', drop); + + emitter.off('ping', drop); + emitter.emit('ping', { id: '7' }); + + expect(keep).toHaveBeenCalledWith({ id: '7' }); + expect(drop).not.toHaveBeenCalled(); + }); + + it('removes all listeners for an event with off(name)', () => { + const emitter = new EventEmitter(); + const a = vi.fn(); + const b = vi.fn(); + emitter.on('ping', a); + emitter.on('ping', b); + + emitter.off('ping'); + emitter.emit('ping', { id: '7' }); + + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it('once() fires exactly one time', () => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.once('ping', listener); + emitter.emit('ping', { id: '1' }); + emitter.emit('ping', { id: '2' }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ id: '1' }); + }); + + it('removeAllListeners() clears every subscription', () => { + const emitter = new EventEmitter(); + const a = vi.fn(); + const b = vi.fn(); + emitter.on('ping', a); + emitter.on('empty', b); + + emitter.removeAllListeners(); + emitter.emit('ping', { id: '1' }); + emitter.emit('empty'); + + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/superdoc/src/core/EventEmitter.ts b/packages/superdoc/src/core/EventEmitter.ts index d15df67354..6bce2438fb 100644 --- a/packages/superdoc/src/core/EventEmitter.ts +++ b/packages/superdoc/src/core/EventEmitter.ts @@ -1,17 +1,24 @@ /** - * Default event map with string keys and any arguments. - * Using `any[]` is necessary here to allow flexible event argument types - * while maintaining type safety through generic constraints in EventEmitter. + * Default event map: string event names โ†’ tuple of payload args. + * + * The index-signature value is `unknown[]` (SD-3213 EventEmitter drain). + * Specific event maps that extend this still type their known events + * precisely; the index-signature fallback only governs untyped event + * names. Currently this emitter is consumed by `Whiteboard` (no event + * map yet โ€” tracked as a follow-up); SuperDoc itself uses the + * third-party `eventemitter3` and is unaffected by this change. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DefaultEventMap = Record; +export type DefaultEventMap = Record; /** * Event callback function type. - * Using `any[]` default is necessary for variance and compatibility with event handlers. + * + * Default `Args extends unknown[] = unknown[]` (was `any[]`, SD-3213). + * Variance: when a specific event map provides a tighter tuple via + * `EventMap[K]`, that flows through to `EventCallback` at + * the call site, so typed events keep their precise payloads. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type EventCallback = (...args: Args) => void; +export type EventCallback = (...args: Args) => void; /** * EventEmitter class is used to emit and subscribe to events. @@ -28,8 +35,11 @@ export class EventEmitter { */ on(name: K, fn: EventCallback): void { const callbacks = this.#events.get(name); - if (callbacks) callbacks.push(fn); - else this.#events.set(name, [fn]); + // Storage erases the per-event tuple type to `EventCallback` (default + // `unknown[]`); the typed `fn` is sound at runtime because `emit` + // re-narrows via `EventMap[K]` on the way out. + if (callbacks) callbacks.push(fn as EventCallback); + else this.#events.set(name, [fn as EventCallback]); } /** diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 59d9a729d1..478290f3ff 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -75,6 +75,127 @@ const DEFAULT_AWARENESS_PALETTE = Object.freeze([ /** @typedef {import('./types/index.js').NavigableAddress} NavigableAddress */ /** @typedef {import('@superdoc/super-editor/ui').SuperDocLike} SuperDocLike */ +/** @typedef {import('./types/index.js').AwarenessState} AwarenessState */ +/** @typedef {import('@superdoc/super-editor').Comment} Comment */ +/** @typedef {import('@superdoc/super-editor').FontsResolvedPayload} FontsResolvedPayload */ +/** @typedef {import('@superdoc/super-editor').ListDefinitionsPayload} ListDefinitionsPayload */ +/** + * Discriminator for `comments-update` payloads. Inlined rather than + * imported from `@superdoc/common` because the dist re-export path for + * `CommentEvent` doesn't survive the facade emit; the source values + * live in `shared/common/event-types.d.ts` (`comments_module_events`). + * + * @typedef {'resolved' | 'new' | 'add' | 'update' | 'deleted' | 'pending' | 'selected' | 'comments-list' | 'change-accepted' | 'change-rejected'} CommentEvent + */ +/** @typedef {import('./whiteboard/Whiteboard.js').WhiteboardData} WhiteboardData */ + +/** + * Typed event map for `superdoc.on(name, fn)` / `superdoc.emit(name, ...)`. + * + * The map is closed: unknown event names (e.g. `superdoc.on('reayd', ...)`, + * a typo) are TS errors at compile time. This is a **TS-only tightening**. + * The runtime `eventemitter3` still accepts any string; it is consumers + * relying on dynamic event names who would see new type errors. Verified + * no internal SuperDoc code emits or subscribes to dynamic event names + * (every emit site is enumerated here). + * + * `exception` is typed as `SuperDocExceptionPayload`, a union of the three + * shapes the runtime currently emits today: `{ error, stage, document }` + * from `superdoc-store.js` document-init failures, `{ error, document }` + * from the catch in `restoreUnsavedChanges()`, and `{ error, editor?, code?, + * documentId? }` from `SuperDoc.vue` editor lifecycle. Normalizing these + * is tracked as a separate follow-up; this map types the current reality + * so consumers get a union they can narrow with `'stage' in payload` etc. + * + * `fonts-resolved` uses a **listener-transport pattern**: SuperDoc never + * emits it directly. Instead, `SuperDoc.vue:719` reads the registered + * `superdoc.listeners('fonts-resolved')[0]` and threads it into the new + * editor's `onFontsResolved` option. Cleanup of this transport (relay + * through SuperDoc instead) is a follow-up; typing it here matches the + * current consumer-visible contract. + * + * @typedef {{ + * ready: [SuperDocReadyPayload], + * editorBeforeCreate: [SuperDocEditorPayload], + * editorCreate: [SuperDocEditorPayload], + * editorDestroy: [], + * 'pdf:document-ready': [], + * 'sidebar-toggle': [boolean], + * zoomChange: [SuperDocZoomPayload], + * 'formatting-marks-change': [SuperDocFormattingMarksPayload], + * 'document-mode-change': [SuperDocDocumentModeChangePayload], + * 'editor-update': [SuperDocEditorUpdatePayload], + * 'content-error': [SuperDocContentErrorPayload], + * 'fonts-resolved': [FontsResolvedPayload], + * 'pagination-update': [SuperDocPaginationPayload], + * 'list-definitions-change': [ListDefinitionsPayload], + * 'comments-update': [SuperDocCommentsUpdatePayload], + * 'collaboration-ready': [SuperDocEditorPayload], + * 'awareness-update': [SuperDocAwarenessUpdatePayload], + * locked: [SuperDocLockedPayload], + * 'whiteboard:init': [SuperDocWhiteboardPayload], + * 'whiteboard:ready': [SuperDocWhiteboardPayload], + * 'whiteboard:change': [WhiteboardData], + * 'whiteboard:enabled': [boolean], + * 'whiteboard:tool': [string], + * exception: [SuperDocExceptionPayload], + * }} SuperDocEventMap + * + * @typedef {{ superdoc: SuperDoc }} SuperDocReadyPayload + * @typedef {{ editor: Editor }} SuperDocEditorPayload + * @typedef {{ whiteboard: Whiteboard }} SuperDocWhiteboardPayload + * @typedef {{ zoom: number }} SuperDocZoomPayload + * @typedef {{ showFormattingMarks: boolean, superdoc: SuperDoc }} SuperDocFormattingMarksPayload + * @typedef {{ documentMode: DocumentMode }} SuperDocDocumentModeChangePayload + * @typedef {{ totalPages: number, superdoc: SuperDoc }} SuperDocPaginationPayload + * @typedef {{ error: unknown, editor: Editor }} SuperDocContentErrorPayload + * @typedef {{ isLocked: boolean, lockedBy?: User | null }} SuperDocLockedPayload + * + * Editor-update envelope produced by `buildEditorPayloadBase` in + * `SuperDoc.vue`. `editor` is `effectiveEditor = editor ?? sourceEditor`, + * which is `undefined` only when neither was provided. + * + * @typedef {{ + * editor?: Editor, + * sourceEditor?: Editor, + * surface: string, + * headerId: string | null, + * sectionType: string | null, + * }} SuperDocEditorUpdatePayload + * + * Awareness payload from `awarenessHandler` in `collaboration.js`. + * `states` is `AwarenessState[]` (the publicly re-exported shape). + * `added` and `removed` are Yjs client IDs (numbers). + * + * @typedef {{ + * states: AwarenessState[], + * added: number[], + * removed: number[], + * superdoc: SuperDoc, + * }} SuperDocAwarenessUpdatePayload + * + * Comments-update envelope from `comments-store.js`. `type` is one of the + * `CommentEvent` literals; `comment` is present for ADD/UPDATE/DELETED/NEW/ + * RESOLVED and absent for PENDING. `changes` is present for DELETED to + * carry the per-document change record. + * + * @typedef {{ + * type: CommentEvent, + * comment?: Comment, + * changes?: Array<{ key: string, commentId: string, fileId?: string | null }>, + * }} SuperDocCommentsUpdatePayload + * + * Union of the three current `exception` payload shapes (debt: should be + * normalized to one shape in a follow-up). Consumers can narrow with + * `'stage' in payload` (store init) or `'code' in payload` (Vue editor + * lifecycle). + * + * @typedef {{ error: Error, stage: 'document-init', document: Document | null | undefined }} SuperDocExceptionStorePayload + * @typedef {{ error: unknown, document: Document }} SuperDocExceptionRestorePayload + * @typedef {{ error: unknown, editor?: Editor, code?: string, documentId?: string | null }} SuperDocExceptionEditorPayload + * @typedef {SuperDocExceptionStorePayload | SuperDocExceptionRestorePayload | SuperDocExceptionEditorPayload} SuperDocExceptionPayload + */ + /** * Config callbacks are optional on the public typedef because consumers do * not need to pass them. The fields wrapped by this helper (every callback @@ -99,7 +220,7 @@ function asEventListener(listener) { * Expects a config object * * @class - * @extends EventEmitter + * @extends {EventEmitter} * @implements {SuperDocLike} */ export class SuperDoc extends EventEmitter { @@ -161,17 +282,70 @@ export class SuperDoc extends EventEmitter { * the systematic soundness fix across all of these fields (declaring them * `T | undefined` and casting at internal post-init access sites). * + * `@private` is a TypeScript-surface hide, not runtime privacy: the + * fields still exist on the runtime instance and internal callers + * across the package keep working. Consumers can no longer reach into + * them via `.d.ts`, which collapses the Pinia type graph from the + * public surface (SD-3213f). The headless-toolbar host contract was + * refactored in the same PR to replace raw store reach with the + * narrow methods `getPresentationEditorForDocument(documentId)` and + * `getComment(commentId)` below, so SuperDoc instances satisfy + * `HeadlessToolbarSuperdocHost` directly without exposing + * `superdocStore` publicly. + * * @type {ReturnType} + * @private */ superdocStore; - /** @type {ReturnType} */ + /** + * @type {ReturnType} + * @private + */ commentsStore; - /** @type {ReturnType} */ + /** + * @type {ReturnType} + * @private + */ highContrastModeStore; - /** @type {import('vue').App} */ + /** + * Internal mount handle for the `SuperComments` Vue component, created + * lazily by `addCommentsList()` and torn down by `removeCommentsList()`. + * Not consumer API: `SuperComments` is not publicly exported, no docs + * or examples reference `superdoc.commentsList`, and the inner fields + * (`element`, `superdoc` backref, `container` Vue ComponentPublicInstance) + * are internal mount state. + * + * Typed as `SuperComments | null | undefined` so the runtime states + * stay type-clean: `undefined` before `addCommentsList()` runs (e.g. + * when the viewer role skips initialization; see SuperDoc.test.js + * for the assertion), `SuperComments` after `addCommentsList()`, and + * `null` after `removeCommentsList()` tears down. No initializer, to + * match the convention used by the adjacent `@private` store fields. + * + * @type {SuperComments | null | undefined} + * @private + */ + commentsList; + + /** + * Internal Vue app handle created in `#initVueApp()` and used for + * mount/unmount, `provide()`, and `config.globalProperties` setup. + * Not consumer API: no docs or examples reference `superdoc.app`, + * and the only cross-file reader (`SuperComments.createVueApp()` + * at `super-comments-list.js:35`) is a `.js` file under + * `checkJs: false`, so the `@private` boundary does not break + * internal source compilation. + * + * Same SD-3213f-style TS surface hide as + * `superdocStore` / `commentsStore` / `highContrastModeStore` / + * `commentsList`; not runtime privacy. + * + * @type {import('vue').App} + * @private + */ app; /** @type {import('pinia').Pinia} */ @@ -465,6 +639,38 @@ export class SuperDoc extends EventEmitter { }; } + /** + * Look up the PresentationEditor associated with a given documentId. + * Returns null if no document matches or the document has no + * presentation editor. Replaces the legacy + * `superdoc.superdocStore.documents[].getPresentationEditor()` reach + * for `superdoc/headless-toolbar` host routing (SD-3213f). + * + * @param {string} documentId + * @returns {import('@superdoc/super-editor').PresentationEditor | null} + */ + getPresentationEditorForDocument(documentId) { + if (typeof documentId !== 'string' || documentId.length === 0) return null; + const documents = this.superdocStore?.documents ?? []; + const matched = documents.find((doc) => doc?.getEditor?.()?.options?.documentId === documentId); + return matched?.getPresentationEditor?.() ?? null; + } + + /** + * Look up a comment by id. Returns null if not found. Replaces the + * legacy `superdoc.commentsStore.getComment(id)` reach for + * `superdoc/headless-toolbar` helpers (SD-3213f). The return type is + * intentionally wide (`Record | null`) so the public + * surface does not pull the Pinia comment model type graph. + * + * @param {string} commentId + * @returns {Record | null} + */ + getComment(commentId) { + if (typeof commentId !== 'string' || commentId.length === 0) return null; + return this.commentsStore?.getComment?.(commentId) ?? null; + } + /** * Get the SuperDoc container element * @returns {HTMLElement | null} diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 70ab774045..f44af5bc6d 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -407,6 +407,170 @@ describe('SuperDoc core', () => { await expect(instance.scrollToElement('element-1')).resolves.toBe(false); }); + // SD-3213f: narrow public methods that replaced the raw-store reach + // for headless-toolbar host routing and tracked-change enrichment. + // These methods are the public replacement for `superdoc.superdocStore. + // documents[].getPresentationEditor()` and `superdoc.commentsStore. + // getComment(id)` access that consumers used pre-hide. They must work + // correctly (returning matched values, null on miss, null on invalid + // input) and must not throw when the underlying stores are missing + // their methods. + describe('SD-3213f narrow host methods', () => { + describe('getPresentationEditorForDocument', () => { + it('returns the presentation editor for the matching documentId', async () => { + const { superdocStore } = createAppHarness(); + const presentationEditor = { id: 'pe-1' }; + const bodyEditor = { options: { documentId: 'doc-1' } }; + superdocStore.documents = [ + { + getEditor: vi.fn(() => bodyEditor), + getPresentationEditor: vi.fn(() => presentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getPresentationEditorForDocument('doc-1')).toBe(presentationEditor); + }); + + it('returns null when no document matches the id', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + getEditor: vi.fn(() => ({ options: { documentId: 'doc-1' } })), + getPresentationEditor: vi.fn(() => ({ id: 'pe-1' })), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getPresentationEditorForDocument('doc-other')).toBeNull(); + }); + + it('returns null when the matched document has no presentation editor', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + getEditor: vi.fn(() => ({ options: { documentId: 'doc-1' } })), + getPresentationEditor: vi.fn(() => null), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getPresentationEditorForDocument('doc-1')).toBeNull(); + }); + + it('returns null for empty or non-string documentId', async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getPresentationEditorForDocument('')).toBeNull(); + expect(instance.getPresentationEditorForDocument(undefined)).toBeNull(); + expect(instance.getPresentationEditorForDocument(null)).toBeNull(); + expect(instance.getPresentationEditorForDocument(123)).toBeNull(); + }); + }); + + describe('getComment', () => { + it('delegates to commentsStore.getComment and returns the result', async () => { + const { commentsStore } = createAppHarness(); + const storedComment = { id: 'c-1', body: 'hello' }; + commentsStore.getComment = vi.fn(() => storedComment); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getComment('c-1')).toBe(storedComment); + expect(commentsStore.getComment).toHaveBeenCalledWith('c-1'); + }); + + it('returns null when commentsStore returns no comment for the id', async () => { + const { commentsStore } = createAppHarness(); + commentsStore.getComment = vi.fn(() => null); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getComment('c-missing')).toBeNull(); + }); + + it('returns null when commentsStore.getComment is missing', async () => { + const { commentsStore } = createAppHarness(); + // Simulate a store mock that hasn't defined getComment. + commentsStore.getComment = undefined; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getComment('c-1')).toBeNull(); + }); + + it('returns null for empty or non-string commentId', async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.getComment('')).toBeNull(); + expect(instance.getComment(undefined)).toBeNull(); + expect(instance.getComment(null)).toBeNull(); + expect(instance.getComment(123)).toBeNull(); + }); + }); + }); + it('warns when both document object and documents list provided', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createAppHarness(); diff --git a/packages/superdoc/src/core/whiteboard/Whiteboard.js b/packages/superdoc/src/core/whiteboard/Whiteboard.js index 15e373668b..82bb11d843 100644 --- a/packages/superdoc/src/core/whiteboard/Whiteboard.js +++ b/packages/superdoc/src/core/whiteboard/Whiteboard.js @@ -3,8 +3,66 @@ import { WhiteboardPage } from './WhiteboardPage'; /** * @typedef {{ width: number, height: number, originalWidth?: number, originalHeight?: number }} WhiteboardPageSize - * @typedef {{ strokes?: any[], text?: any[], images?: any[] }} WhiteboardPageData - * @typedef {{ pages?: Record }} WhiteboardData + * + * The page-level serialized shape is the normalized one (matches what + * `WhiteboardPage.toJSON()` actually returns). SD-3213 follow-up: + * the previous `any[]` typing meant consumers reading + * `whiteboard.getWhiteboardData().pages[0].strokes` had no + * IntelliSense โ€” and a wrong assumption that fields like `.points` + * or `.x` would be present (runtime gives `pointsN` / `xN`). + * + * @typedef {import('./WhiteboardPage.js').WhiteboardStoredPageData} WhiteboardPageData + * + * Per-page size snapshot recorded in `WhiteboardDataMeta.pageSizes`. + * `originalWidth` / `originalHeight` are `number | null` because + * `getWhiteboardData()` writes `page.originalSize?.width ?? null` when + * the original size is unknown. + * + * @typedef {{ width: number, height: number, originalWidth: number | null, originalHeight: number | null }} WhiteboardPageSizeSnapshot + * + * @typedef {{ pageSizes: Record }} WhiteboardDataMeta + * + * `WhiteboardData` is the **output** shape: what `getWhiteboardData()` + * returns and what the `change` event payload carries. All three + * fields are required because the runtime always populates them. + * Consumers reading the change payload can write + * `data.meta.pageSizes` without optional chaining. + * + * @typedef {{ pages: Record, meta: WhiteboardDataMeta, version: 1 }} WhiteboardData + * + * `WhiteboardDataInput` is the **input** shape accepted by + * `setWhiteboardData(json)`. All fields are optional because the + * runtime only reads `json?.pages`; callers can pass + * `{ pages: {...} }` without supplying `meta` or `version`. A round + * trip works (`setWhiteboardData(getWhiteboardData())`) because + * `WhiteboardData` is structurally assignable to `WhiteboardDataInput`. + * + * @typedef {{ pages?: Record, meta?: WhiteboardDataMeta, version?: number }} WhiteboardDataInput + * + * Registry items the host can attach for UI palettes (stickers, + * comments, etc.). The shape is intentionally extensible: `id` is the + * one field the runtime actually relies on (filter, dedup); everything + * else is consumer-typed via `unknown` so palettes for new domains + * don't need a contract change. + * + * @typedef {{ id?: string | number, [key: string]: unknown }} WhiteboardRegistryItem + * + * Event map for `whiteboard.on(name, fn)` / `whiteboard.emit(name, ...)`. + * Closed map (no DefaultEventMap fallback) because every event the + * runtime emits is enumerated here; an unknown event name should be a + * type error, not an `unknown[]` payload. SD-3213 follow-up to the + * EventEmitter `unknown[]` drain: that change only fixed the + * untyped-event fallback; without this map, listeners on Whiteboard + * still received `unknown[]` because no event was named. + * + * @typedef {{ + * tool: [string], + * enabled: [boolean], + * opacity: [number], + * setData: [WhiteboardPage[]], + * change: [WhiteboardData], + * }} WhiteboardEventMap + * * @typedef {{ * Renderer?: any, * superdoc?: any, @@ -17,6 +75,8 @@ import { WhiteboardPage } from './WhiteboardPage'; /** * Whiteboard manager for multi-page annotations. + * + * @extends {EventEmitter} */ export class Whiteboard extends EventEmitter { #Renderer = null; @@ -65,7 +125,7 @@ export class Whiteboard extends EventEmitter { /** * Register items for a UI palette type (e.g. stickers, comments). * @param {string} type - * @param {any[]} items + * @param {WhiteboardRegistryItem[]} items */ register(type, items) { this.#registry.set(type, items); @@ -74,7 +134,7 @@ export class Whiteboard extends EventEmitter { /** * Get registered items by type. * @param {string} type - * @returns {any[] | undefined} + * @returns {WhiteboardRegistryItem[] | undefined} */ getType(type) { return this.#registry.get(type); @@ -226,7 +286,7 @@ export class Whiteboard extends EventEmitter { /** * Load whiteboard data from JSON. - * @param {WhiteboardData} json + * @param {WhiteboardDataInput} json */ setWhiteboardData(json) { this.#pages.clear(); diff --git a/packages/superdoc/src/core/whiteboard/WhiteboardPage.js b/packages/superdoc/src/core/whiteboard/WhiteboardPage.js index aa6d5a3ac1..be6c4d0a8a 100644 --- a/packages/superdoc/src/core/whiteboard/WhiteboardPage.js +++ b/packages/superdoc/src/core/whiteboard/WhiteboardPage.js @@ -7,9 +7,39 @@ const DEFAULT_TEXT_FONT_SIZE = 18; /** * @typedef {{ width: number, height: number, originalWidth?: number, originalHeight?: number }} WhiteboardPageSize * @typedef {{ x: number, y: number }} Point + * + * Authored input shapes โ€” what `addStroke`/`addText`/`addImage` accept + * from a consumer (pixel-space coordinates, optional ids). The + * add* methods normalize these into the stored shapes below. + * * @typedef {{ points: number[][], color?: string, width?: number, type?: 'draw'|'erase' }} WhiteboardStroke * @typedef {{ id?: string|number, x: number, y: number, content: string, fontSize?: number, width?: number }} WhiteboardTextItem * @typedef {{ id?: string|number, stickerId?: string, x: number, y: number, src: string, width?: number, height?: number, type?: string }} WhiteboardImageItem + * + * Stored / normalized shapes โ€” what the page actually holds in + * `this.strokes` / `this.text` / `this.images` and what gets + * serialized via `toJSON()` and re-applied via `applyData()`. Fields + * suffixed with `N` (pointsN, xN, yN, widthN, heightN, fontSizeN) are + * normalized to the page's 0..1 coordinate space; raw `width`/`height` + * pairs are kept where consumers expect pixel sizes. + * + * SD-3213 follow-up: the public types were previously `any[]` for the + * fields and the toJSON / applyData signatures; consumers reading + * `page.strokes[0].points` would have hit pointsN at runtime with no + * IntelliSense. The named shapes below restore typing without + * over-committing the authored shapes (which the add* methods + * normalize away). + * + * @typedef {{ pointsN: number[][], widthN?: number, color?: string, type?: 'draw'|'erase' }} WhiteboardStoredStroke + * @typedef {{ id: string|number, xN: number, yN: number, content: string, fontSizeN?: number, widthN?: number | null }} WhiteboardStoredTextItem + * @typedef {{ id: string|number, stickerId?: string | number | null, xN: number, yN: number, src: string, widthN?: number | null, heightN?: number | null, type?: string }} WhiteboardStoredImageItem + * + * @typedef {{ + * strokes?: WhiteboardStoredStroke[], + * text?: WhiteboardStoredTextItem[], + * images?: WhiteboardStoredImageItem[], + * }} WhiteboardStoredPageData + * * @typedef {{ * pageIndex: number, * enabled: boolean, @@ -26,13 +56,13 @@ export class WhiteboardPage { /** @type {number|null} */ pageIndex = null; - /** @type {WhiteboardStroke[]} */ + /** @type {WhiteboardStoredStroke[]} */ strokes = []; - /** @type {WhiteboardTextItem[]} */ + /** @type {WhiteboardStoredTextItem[]} */ text = []; - /** @type {WhiteboardImageItem[]} */ + /** @type {WhiteboardStoredImageItem[]} */ images = []; /** @type {WhiteboardPageSize|null} */ @@ -681,7 +711,12 @@ export class WhiteboardPage { /** * Serialize page data. - * @returns {{ strokes: any[], text: any[], stickers: any[] }} + * Returns the page's serializable shape. SD-3213 follow-up: fixed + * stale JSDoc that said `stickers` (which never existed on the + * runtime return) and typed the values as the stored normalized + * shapes instead of `any[]`. + * + * @returns {{ strokes: WhiteboardStoredStroke[], text: WhiteboardStoredTextItem[], images: WhiteboardStoredImageItem[] }} */ toJSON() { return { @@ -693,7 +728,7 @@ export class WhiteboardPage { /** * Apply data to this page and re-render. - * @param {{ strokes?: any[], text?: any[], images?: any[] }} data + * @param {WhiteboardStoredPageData} data */ applyData(data = {}) { const strokes = Array.isArray(data.strokes) ? data.strokes : []; diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 94f4ff9452..c9eb655073 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -1,96 +1,290 @@ /** * SuperDoc public facade: root entry. * - * SD-3178 + SD-3185 (Phase 3 of SD-3175). The path-as-contract source of - * truth for `superdoc` consumers: anything exported here is part of the - * public contract: either supported public API or explicitly documented - * legacy compatibility. Anything outside is implementation detail. Phase - * 4 (the contract switch) flips `package.json#exports` to point at the - * emitted declarations under this tree. + * SD-3212 PR B (Phase 4b re-curation) under SD-3175. This file mirrors + * the classification artifact at + * tests/consumer-typecheck/snapshots/superdoc-root-classification.json * - * Surface organization: - * - * 1. **Configuration and lifecycle.** `SuperDoc` class + its `Config`. - * Editor instance type (`Editor`) plus the Document API surface - * reachable as `editor.doc.*`. - * 2. **Document API (recommended programmatic surface).** `DocumentApi` - * and the supporting selection / address / range / bookmark / block - * / protection types already typed by today's `superdoc` root entry. - * Per `packages/superdoc/AGENTS.md` and the `@deprecated` tags on - * `editor.commands` in `Editor.ts`: this is the supported way to - * read and mutate document content programmatically. - * - * Caveat: Document API does not cover every legacy editor command - * 1:1 today. Field annotations, document section management, AI - * marks, search-session UI state, and several format/diff helpers - * exist as runtime commands without a direct Document API analogue. - * The legacy command surface is being audited against the Document - * API in a separate ticket; consumers reaching for those features - * should expect to keep using `editor.commands.*` for now. - * 3. **Legacy compat โ€” typed for backward compat, not advertised.** - * `EditorCommands` and the command-augmentation infrastructure - * type the deprecated `editor.commands.*` surface. They remain - * exported so existing TS consumers keep compiling; new code should - * use `editor.doc.*` (Document API). + * Three tiers: + * 1. Supported root: documented public API; first-class root surface. + * 2. Legacy root: typed for backward compatibility; not the recommended + * path. Per-name @deprecated JSDoc only where a replacement exists. + * 3. Internal candidate: accidental implementation leak; kept typed under + * compat re-export with @internal so a future major can remove it. + * Today these only exist at root because at least one supported or + * legacy export reaches them transitively (see closure gate at + * tests/consumer-typecheck/check-root-classification-closure.mjs). * * Rules for this file: - * - AIDEV-NOTE: Named exports only. No `export *` from implementation - * barrels. `export *` re-introduces the leak this facade exists to - * close โ€” see SD-3175 (path-as-contract umbrella) for context. - * - Explicit `.js` source specifiers (the dts plugin emits `.js` - * specifiers; source consistency keeps the two aligned). - * - AIDEV-NOTE: Adding or removing an export here is a deliberate - * public-API decision. In the same PR, update the `expectedNames` - * for the `root (./index)` entry in `FACADE_ENTRIES` inside - * `packages/superdoc/scripts/verify-public-facade-emit.cjs` and - * link to SD-3175 (or a child ticket) for reviewer sign-off. - * Skipping the expectedNames update fails the postbuild gate. - * - AIDEV-NOTE: Document API additions are encouraged (it is the - * supported programmatic contract). Editor-command additions are - * not โ€” that surface is deprecated. New entries that type the - * `editor.commands.*` surface should be flagged in review. + * - AIDEV-NOTE: Named exports only. No `export *`. Changing the surface + * here updates the classification artifact and the + * verify-public-facade-emit.cjs FACADE_ENTRIES['root (./index)'].expectedNames + * in the same PR. Skipping either fails the postbuild gate or the + * consumer-typecheck snapshot. + * - The CI closure gate enforces that no supported-root or legacy-root + * export references an internal-candidate root symbol in its declared + * type. Overrides require a reason string per + * check-root-classification-closure.mjs. + * - Per-name `@deprecated` JSDoc only fires for names with a real + * migration target. Section-level framing carries the "legacy compat" + * intent for names where no replacement exists today (avoids + * "deprecated to nothing" noise in customer IDEs). */ -// (1) Configuration and lifecycle. +// The common package is workspace-private. Source imports stay readable here; +// ensure-types.cjs strips and inlines the emitted declarations so consumers +// never resolve @superdoc/common from the packed package. +import { DOCX, PDF, HTML, getFileObject, compareVersions } from '@superdoc/common'; +// @ts-expect-error Vite resolves DOCX asset URL imports; plain tsc does not. +import BlankDOCXAsset from '@superdoc/common/data/blank.docx?url'; +/** URL to the blank DOCX template. */ +export const BlankDOCX: string = BlankDOCXAsset; +export { DOCX, PDF, HTML, getFileObject, compareVersions }; + +// ============================================================================= +// SUPPORTED ROOT (132) +// First-class public API. Documented, advertised, supported long-term. +// ============================================================================= + +// Source: ./core/SuperDoc.js export { SuperDoc } from '../core/SuperDoc.js'; + +// Source: ./core/theme/create-theme.ts +export { buildTheme } from '../core/theme/create-theme.js'; +export { createTheme } from '../core/theme/create-theme.js'; + +// Type source: ./core/types/index.js +export type { AwarenessState } from '../core/types/index.js'; +export type { BlockNavigationAddress } from '../core/types/index.js'; +export type { BookmarkAddress } from '../core/types/index.js'; +export type { CollaborationConfig } from '../core/types/index.js'; +export type { CommentAddress } from '../core/types/index.js'; +export type { CommentsType } from '../core/types/index.js'; export type { Config } from '../core/types/index.js'; +export type { DirectSurfaceRequest } from '../core/types/index.js'; +export type { DocRange } from '../core/types/index.js'; +export type { DocumentMode } from '../core/types/index.js'; +export type { EditorSurface } from '../core/types/index.js'; +export type { EditorTransactionEvent } from '../core/types/index.js'; +export type { EditorUpdateEvent } from '../core/types/index.js'; +export type { ExportParams } from '../core/types/index.js'; +export type { ExportType } from '../core/types/index.js'; +export type { ExternalPopoverRenderContext } from '../core/types/index.js'; +export type { ExternalSurfaceRenderContext } from '../core/types/index.js'; +export type { FindReplaceContext } from '../core/types/index.js'; +export type { FindReplaceHandle } from '../core/types/index.js'; +export type { FindReplaceRenderContext } from '../core/types/index.js'; +export type { FindReplaceResolution } from '../core/types/index.js'; +export type { IntentSurfaceRequest } from '../core/types/index.js'; +export type { Modules } from '../core/types/index.js'; +export type { NavigableAddress } from '../core/types/index.js'; +export type { PasswordPromptAttemptResult } from '../core/types/index.js'; +export type { PasswordPromptConfig } from '../core/types/index.js'; +export type { PasswordPromptContext } from '../core/types/index.js'; +export type { PasswordPromptHandle } from '../core/types/index.js'; +export type { PasswordPromptRenderContext } from '../core/types/index.js'; +export type { PasswordPromptResolution } from '../core/types/index.js'; +export type { ResolvedFindReplaceTexts } from '../core/types/index.js'; +export type { ResolvedPasswordPromptTexts } from '../core/types/index.js'; +export type { SearchMatch } from '../core/types/index.js'; +export type { StoryLocator } from '../core/types/index.js'; +export type { SuperDocLayoutEngineOptions } from '../core/types/index.js'; +export type { SuperDocTelemetryConfig } from '../core/types/index.js'; +export type { SurfaceComponentProps } from '../core/types/index.js'; +export type { SurfaceFloatingPlacement } from '../core/types/index.js'; +export type { SurfaceHandle } from '../core/types/index.js'; +export type { SurfaceMode } from '../core/types/index.js'; +export type { SurfaceOutcome } from '../core/types/index.js'; +export type { SurfaceRequest } from '../core/types/index.js'; +export type { SurfaceResolution } from '../core/types/index.js'; +export type { SurfaceResolver } from '../core/types/index.js'; +export type { SurfacesModuleConfig } from '../core/types/index.js'; +export type { TrackChangesModuleConfig } from '../core/types/index.js'; +export type { TrackedChangeAddress } from '../core/types/index.js'; +export type { UpgradeToCollaborationOptions } from '../core/types/index.js'; +export type { ViewingVisibilityConfig } from '../core/types/index.js'; + +// Source: ./helpers/schema-introspection.js +export { getSchemaIntrospection } from '../helpers/schema-introspection.js'; + +// `compareVersions`, `DOCX`, `getFileObject`, `HTML`, `PDF` and `BlankDOCX` +// from `@superdoc/common` are handled at the top of this file +// (import-then-export pattern; see comment there for rationale). + +// Source: @superdoc/super-editor +export { assertNodeType } from '@superdoc/super-editor'; +export { createZip } from '@superdoc/super-editor'; +export { defineMark } from '@superdoc/super-editor'; +export { defineNode } from '@superdoc/super-editor'; export { Editor } from '@superdoc/super-editor'; +export { Extensions } from '@superdoc/super-editor'; +export { getActiveFormatting } from '@superdoc/super-editor'; +export { getAllowedImageDimensions } from '@superdoc/super-editor'; +export { getMarksFromSelection } from '@superdoc/super-editor'; +export { getRichTextExtensions } from '@superdoc/super-editor'; +export { getStarterExtensions } from '@superdoc/super-editor'; +export { isMarkType } from '@superdoc/super-editor'; +export { isNodeType } from '@superdoc/super-editor'; -// (2) Document API โ€” the recommended programmatic surface (`editor.doc.*`). -// This set mirrors the JSDoc `@typedef` block in `packages/superdoc/src/index.js` -// and is already covered by `tests/consumer-typecheck/src/all-public-types.ts`. -// It is not surface growth; it is preserving the current root type contract -// in the new path-as-contract facade. -export type { - DocumentApi, - SelectionApi, - SelectionInfo, - SelectionCurrentInput, - ScrollIntoViewInput, - ScrollIntoViewOutput, - ResolveRangeOutput, - TextTarget, - TextAddress, - TextSegment, - EntityAddress, - BlocksListResult, - BookmarkInfo, - BookmarkAddress, - BlockNavigationAddress, - CommentAddress, - TrackedChangeAddress, - NavigableAddress, - StoryLocator, - DocumentProtectionState, -} from '@superdoc/super-editor'; - -// (3) Legacy command-augmentation infrastructure โ€” typed for backward -// compat, not advertised. -/** - * @deprecated Editor commands are deprecated and will be removed in a - * future version. Use the Document API via `editor.doc.*` for - * programmatic document reads and mutations. See - * `packages/superdoc/AGENTS.md` and the `@deprecated` tags on - * `editor.commands` in `Editor.ts` (lines 1411, 1597, 1605). - */ +// Type source: @superdoc/super-editor +export type { BinaryData } from '@superdoc/super-editor'; +export type { BlocksListResult } from '@superdoc/super-editor'; +export type { BookmarkInfo } from '@superdoc/super-editor'; +export type { CollaborationProvider } from '@superdoc/super-editor'; +export type { Comment } from '@superdoc/super-editor'; +export type { CommentConfig } from '@superdoc/super-editor'; +export type { CommentElement } from '@superdoc/super-editor'; +export type { CommentLocationsPayload } from '@superdoc/super-editor'; +export type { CommentsPayload } from '@superdoc/super-editor'; +export type { DocumentApi } from '@superdoc/super-editor'; +export type { DocumentProtectionState } from '@superdoc/super-editor'; +export type { DocxFileEntry } from '@superdoc/super-editor'; +export type { EditorEventMap } from '@superdoc/super-editor'; +export type { EditorLifecycleState } from '@superdoc/super-editor'; +export type { EntityAddress } from '@superdoc/super-editor'; +export type { ExportDocxParams } from '@superdoc/super-editor'; +export type { ExportFormat } from '@superdoc/super-editor'; +export type { ExportOptions } from '@superdoc/super-editor'; +export type { FieldValue } from '@superdoc/super-editor'; +export type { FontConfig } from '@superdoc/super-editor'; +export type { FontsResolvedPayload } from '@superdoc/super-editor'; +export type { ImageDeselectedEvent } from '@superdoc/super-editor'; +export type { ImageSelectedEvent } from '@superdoc/super-editor'; +export type { LinkPopoverContext } from '@superdoc/super-editor'; +export type { LinkPopoverResolution } from '@superdoc/super-editor'; +export type { LinkPopoverResolver } from '@superdoc/super-editor'; +export type { OpenOptions } from '@superdoc/super-editor'; +export type { PageMargins } from '@superdoc/super-editor'; +export type { PageSize } from '@superdoc/super-editor'; +export type { PageStyles } from '@superdoc/super-editor'; +export type { PartChangedEvent } from '@superdoc/super-editor'; +export type { PermissionParams } from '@superdoc/super-editor'; +export type { ProofingCapabilities } from '@superdoc/super-editor'; +export type { ProofingCheckRequest } from '@superdoc/super-editor'; +export type { ProofingCheckResult } from '@superdoc/super-editor'; +export type { ProofingConfig } from '@superdoc/super-editor'; +export type { ProofingError } from '@superdoc/super-editor'; +export type { ProofingIssue } from '@superdoc/super-editor'; +export type { ProofingIssueKind } from '@superdoc/super-editor'; +export type { ProofingProvider } from '@superdoc/super-editor'; +export type { ProofingSegment } from '@superdoc/super-editor'; +export type { ProofingSegmentMetadata } from '@superdoc/super-editor'; +export type { ProofingStatus } from '@superdoc/super-editor'; +export type { ProtectionChangeSource } from '@superdoc/super-editor'; +export type { ResolveRangeOutput } from '@superdoc/super-editor'; +export type { SaveOptions } from '@superdoc/super-editor'; +export type { ScrollIntoViewInput } from '@superdoc/super-editor'; +export type { ScrollIntoViewOutput } from '@superdoc/super-editor'; +export type { SelectionApi } from '@superdoc/super-editor'; +export type { SelectionCommandContext } from '@superdoc/super-editor'; +export type { SelectionCurrentInput } from '@superdoc/super-editor'; +export type { SelectionHandle } from '@superdoc/super-editor'; +export type { SelectionInfo } from '@superdoc/super-editor'; +export type { TextAddress } from '@superdoc/super-editor'; +export type { TextSegment } from '@superdoc/super-editor'; +export type { TextTarget } from '@superdoc/super-editor'; +export type { TrackedChangesMode } from '@superdoc/super-editor'; +export type { UnsupportedContentItem } from '@superdoc/super-editor'; +export type { User } from '@superdoc/super-editor'; +export type { ViewLayout } from '@superdoc/super-editor'; +export type { ViewOptions } from '@superdoc/super-editor'; + +// ============================================================================= +// LEGACY ROOT (60) +// Typed for backward compatibility. Not the recommended root story. +// Per-name @deprecated JSDoc applied below where a clear replacement exists. +// ============================================================================= + +// Type source: ./core/types/index.js +export type { ContextMenuConfig } from '../core/types/index.js'; +export type { ContextMenuContext } from '../core/types/index.js'; +export type { ContextMenuItem } from '../core/types/index.js'; +export type { ContextMenuSection } from '../core/types/index.js'; +export type { FindReplaceConfig } from '../core/types/index.js'; + +// BlankDOCX is handled via the import-then-export pattern at the top of this file. + +// Source: @superdoc/super-editor +export { AIWriter } from '@superdoc/super-editor'; +export { CommentsPluginKey } from '@superdoc/super-editor'; +export { ContextMenu } from '@superdoc/super-editor'; +export { DocxZipper } from '@superdoc/super-editor'; +export { fieldAnnotationHelpers } from '@superdoc/super-editor'; +export { PresentationEditor } from '@superdoc/super-editor'; +export { SlashMenu } from '@superdoc/super-editor'; +export { SuperConverter } from '@superdoc/super-editor'; +export { SuperEditor } from '@superdoc/super-editor'; +export { SuperInput } from '@superdoc/super-editor'; +export { SuperToolbar } from '@superdoc/super-editor'; +export { Toolbar } from '@superdoc/super-editor'; +export { TrackChangesBasePluginKey } from '@superdoc/super-editor'; + +// Type source: @superdoc/super-editor +export type { BoundingRect } from '@superdoc/super-editor'; +export type { CanObject } from '@superdoc/super-editor'; +export type { ChainableCommandObject } from '@superdoc/super-editor'; +export type { ChainedCommand } from '@superdoc/super-editor'; +export type { Command } from '@superdoc/super-editor'; +export type { CommandProps } from '@superdoc/super-editor'; +export type { CoreCommandMap } from '@superdoc/super-editor'; export type { EditorCommands } from '@superdoc/super-editor'; +export type { EditorExtension } from '@superdoc/super-editor'; +export type { EditorOptions } from '@superdoc/super-editor'; +export type { EditorState } from '@superdoc/super-editor'; +export type { EditorView } from '@superdoc/super-editor'; +export type { ExtensionCommandMap } from '@superdoc/super-editor'; +export type { FlowBlock } from '@superdoc/super-editor'; +export type { FlowMode } from '@superdoc/super-editor'; +export type { Layout } from '@superdoc/super-editor'; +export type { LayoutEngineOptions } from '@superdoc/super-editor'; +export type { LayoutError } from '@superdoc/super-editor'; +export type { LayoutFragment } from '@superdoc/super-editor'; +export type { LayoutMetrics } from '@superdoc/super-editor'; +export type { LayoutMode } from '@superdoc/super-editor'; +export type { LayoutPage } from '@superdoc/super-editor'; +export type { LayoutState } from '@superdoc/super-editor'; +export type { ListDefinitionsPayload } from '@superdoc/super-editor'; +export type { Measure } from '@superdoc/super-editor'; +export type { PaginationPayload } from '@superdoc/super-editor'; +export type { PaintSnapshot } from '@superdoc/super-editor'; +export type { PartId } from '@superdoc/super-editor'; +export type { PartSectionId } from '@superdoc/super-editor'; +export type { PositionHit } from '@superdoc/super-editor'; +export type { PresenceOptions } from '@superdoc/super-editor'; +export type { PresentationEditorOptions } from '@superdoc/super-editor'; +export type { ProseMirrorJSON } from '@superdoc/super-editor'; +export type { RangeRect } from '@superdoc/super-editor'; +export type { RemoteCursorState } from '@superdoc/super-editor'; +export type { RemoteUserInfo } from '@superdoc/super-editor'; +export type { Schema } from '@superdoc/super-editor'; +export type { SectionMetadata } from '@superdoc/super-editor'; +export type { TrackedChangesOverrides } from '@superdoc/super-editor'; +export type { Transaction } from '@superdoc/super-editor'; +export type { VirtualizationOptions } from '@superdoc/super-editor'; + +// ============================================================================= +// INTERNAL CANDIDATE (8) +// Should not be public long-term. Kept typed under compat re-export because +// at least one supported/legacy export reaches them transitively. Removal +// planned for a major-version cleanup (see SD-3212 follow-ups). +// ============================================================================= + +// Source: @superdoc/super-editor +/** @internal */ +export { AnnotatorHelpers } from '@superdoc/super-editor'; +/** @internal */ +export { registeredHandlers } from '@superdoc/super-editor'; +/** @internal */ +export { SectionHelpers } from '@superdoc/super-editor'; +/** @internal */ +export { helpers as superEditorHelpers } from '@superdoc/super-editor'; +/** @internal */ +export { trackChangesHelpers } from '@superdoc/super-editor'; + +// Type source: @superdoc/super-editor +/** @internal */ +export type { LayoutUpdatePayload } from '@superdoc/super-editor'; +/** @internal */ +export type { RemoteCursorsRenderPayload } from '@superdoc/super-editor'; +/** @internal */ +export type { TelemetryEvent } from '@superdoc/super-editor'; diff --git a/packages/superdoc/src/public/ui.ts b/packages/superdoc/src/public/ui.ts index 852247fce6..8520a1855c 100644 --- a/packages/superdoc/src/public/ui.ts +++ b/packages/superdoc/src/public/ui.ts @@ -2,11 +2,11 @@ * SuperDoc public facade: ui entry. * * SD-3183 under SD-3178 (Phase 3 of SD-3175). Largest supported-surface - * facade entry. Mirrors the 70-name surface today reachable via the - * `superdoc/ui` subpath: 3 runtime values + 67 types. + * facade entry. Mirrors the 71-name surface today reachable via the + * `superdoc/ui` subpath: 3 runtime values + 68 types. * - * Classification per SD-3147: 49 public + 21 legacy/public-compat. All - * 70 re-exported through the facade โ€” tier distinction is documentation + * Classification per SD-3147: 50 public + 21 legacy/public-compat. All + * 71 re-exported through the facade โ€” tier distinction is documentation * posture, not facade inclusion. * * Strategy: re-export through the narrow `@superdoc/super-editor/ui` @@ -68,6 +68,7 @@ export type { DynamicCommandHandle, EntityAddress, EqualityFn, + MetadataHandle, Receipt, ScrollIntoViewInput, ScrollIntoViewOutput, diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 5b86fb6d60..90dc9db460 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -618,16 +618,13 @@ export const useCommentsStore = defineStore('comments', () => { didChange = setIfChanged(target, 'trackedChangeStory', normalizedTrackedChangeStory) || didChange; } if (normalizedTrackedChangeStoryKind !== undefined && normalizedTrackedChangeStoryKind !== null) { - didChange = - setIfChanged(target, 'trackedChangeStoryKind', normalizedTrackedChangeStoryKind) || didChange; + didChange = setIfChanged(target, 'trackedChangeStoryKind', normalizedTrackedChangeStoryKind) || didChange; } if (normalizedTrackedChangeStoryLabel !== undefined && normalizedTrackedChangeStoryLabel !== '') { - didChange = - setIfChanged(target, 'trackedChangeStoryLabel', normalizedTrackedChangeStoryLabel) || didChange; + didChange = setIfChanged(target, 'trackedChangeStoryLabel', normalizedTrackedChangeStoryLabel) || didChange; } if (normalizedTrackedChangeAnchorKey !== undefined && normalizedTrackedChangeAnchorKey !== null) { - didChange = - setIfChanged(target, 'trackedChangeAnchorKey', normalizedTrackedChangeAnchorKey) || didChange; + didChange = setIfChanged(target, 'trackedChangeAnchorKey', normalizedTrackedChangeAnchorKey) || didChange; } return didChange; }; diff --git a/packages/superdoc/src/ui.barrel.test.ts b/packages/superdoc/src/ui.barrel.test.ts index 36d353f063..b8bdd50ab7 100644 --- a/packages/superdoc/src/ui.barrel.test.ts +++ b/packages/superdoc/src/ui.barrel.test.ts @@ -59,3 +59,9 @@ describe('superdoc/ui public barrel (SD-3157)', () => { expect(BARREL_TEXT).toMatch(/type\s+ContentControlsHandle\b/); }); }); + +describe('superdoc/ui public barrel (SD-3204)', () => { + it('re-exports MetadataHandle', () => { + expect(BARREL_TEXT).toMatch(/type\s+MetadataHandle\b/); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index d4b3efa810..4f6faca482 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -26,6 +26,7 @@ export { type DynamicCommandHandle, type EntityAddress, type EqualityFn, + type MetadataHandle, type Receipt, type ScrollIntoViewInput, type ScrollIntoViewOutput, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 869ae1a9a1..dfaf8f8f84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,10 +338,6 @@ patchedDependencies: importers: .: - dependencies: - superdoc: - specifier: workspace:* - version: link:packages/superdoc devDependencies: '@clack/prompts': specifier: ^1.0.1 @@ -562,7 +558,7 @@ importers: version: 14.0.3 mintlify: specifier: 4.2.531 - version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -638,31 +634,6 @@ importers: specifier: ^1.50.0 version: 1.58.2 - demos/chrome-extension/chrome-extension: - dependencies: - fast-xml-parser: - specifier: ^5.2.5 - version: 5.5.9 - jszip: - specifier: ^3.10.1 - version: 3.10.1 - prosemirror-model: - specifier: ^1.25.1 - version: 1.25.4 - prosemirror-state: - specifier: ^1.4.3 - version: 1.4.4 - prosemirror-transform: - specifier: ^1.10.4 - version: 1.11.0 - devDependencies: - webpack: - specifier: ^5.99.9 - version: 5.105.4(esbuild@0.27.7)(webpack-cli@6.0.1) - webpack-cli: - specifier: ^6.0.1 - version: 6.0.1(webpack@5.105.4) - demos/collaborative-agent/client: dependencies: '@radix-ui/react-collapsible': @@ -798,53 +769,6 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - demos/custom-ui: - dependencies: - '@superdoc-dev/react': - specifier: workspace:* - version: link:../../packages/react - react: - specifier: 'catalog:' - version: 19.2.4 - react-dom: - specifier: 'catalog:' - version: 19.2.4(react@19.2.4) - superdoc: - specifier: workspace:* - version: link:../../packages/superdoc - devDependencies: - '@types/react': - specifier: 'catalog:' - version: 19.2.14 - '@types/react-dom': - specifier: 'catalog:' - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: 'catalog:' - version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - typescript: - specifier: 'catalog:' - version: 5.9.3 - vite: - specifier: npm:rolldown-vite@7.3.1 - version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - - demos/docx-from-html: - dependencies: - superdoc: - specifier: workspace:* - version: link:../../packages/superdoc - vue: - specifier: 3.5.32 - version: 3.5.32(typescript@5.9.3) - devDependencies: - '@vitejs/plugin-vue': - specifier: ^5.2.2 - version: 5.2.4(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) - vite: - specifier: npm:rolldown-vite@7.3.1 - version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - demos/docxtemplater: dependencies: '@fortawesome/fontawesome-svg-core': @@ -897,6 +821,221 @@ importers: specifier: ^7.7.1 version: 7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(vue@3.5.32(typescript@5.9.3)) + demos/editor/custom-ui: + dependencies: + '@superdoc-dev/react': + specifier: workspace:* + version: link:../../../packages/react + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + superdoc: + specifier: workspace:* + version: link:../../../packages/superdoc + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + demos/editor/integrations/chrome-extension/chrome-extension: + dependencies: + fast-xml-parser: + specifier: ^5.2.5 + version: 5.5.9 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + prosemirror-model: + specifier: ^1.25.1 + version: 1.25.4 + prosemirror-state: + specifier: ^1.4.3 + version: 1.4.4 + prosemirror-transform: + specifier: ^1.10.4 + version: 1.11.0 + devDependencies: + webpack: + specifier: ^5.99.9 + version: 5.105.4(esbuild@0.27.7)(webpack-cli@6.0.1) + webpack-cli: + specifier: ^6.0.1 + version: 6.0.1(webpack@5.105.4) + + demos/editor/integrations/word-addin: + dependencies: + '@google-cloud/storage': + specifier: ^7.13.0 + version: 7.19.0 + auth0-js: + specifier: ^9.28.0 + version: 9.32.0 + core-js: + specifier: ^3.36.0 + version: 3.49.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^5.1.0 + version: 5.2.1 + express-oauth2-jwt-bearer: + specifier: ^1.6.0 + version: 1.7.4 + jsdom: + specifier: 27.3.0 + version: 27.3.0(canvas@3.2.3) + regenerator-runtime: + specifier: ^0.14.1 + version: 0.14.1 + ws: + specifier: ^8.18.0 + version: 8.20.0 + devDependencies: + '@babel/core': + specifier: ^7.24.0 + version: 7.29.0 + '@babel/preset-env': + specifier: ^7.25.4 + version: 7.29.2(@babel/core@7.29.0) + '@types/office-js': + specifier: ^1.0.377 + version: 1.0.585 + '@types/office-runtime': + specifier: ^1.0.35 + version: 1.0.36 + acorn: + specifier: ^8.11.3 + version: 8.16.0 + babel-loader: + specifier: ^9.1.3 + version: 9.2.1(@babel/core@7.29.0)(webpack@5.105.4) + copy-webpack-plugin: + specifier: ^12.0.2 + version: 12.0.2(webpack@5.105.4) + eslint-plugin-office-addins: + specifier: ^3.0.2 + version: 3.0.3(@eslint/eslintrc@3.3.5)(@types/eslint@9.6.1) + file-loader: + specifier: ^6.2.0 + version: 6.2.0(webpack@5.105.4) + html-loader: + specifier: ^5.0.0 + version: 5.1.0(webpack@5.105.4) + html-webpack-plugin: + specifier: ^5.6.3 + version: 5.6.6(webpack@5.105.4) + office-addin-cli: + specifier: ^1.6.5 + version: 1.6.5 + office-addin-debugging: + specifier: ^5.1.6 + version: 5.1.6(@microsoft/teamsapp-cli@3.0.2) + office-addin-dev-certs: + specifier: ^1.13.5 + version: 1.13.5 + office-addin-lint: + specifier: ^2.3.5 + version: 2.3.5(@eslint/eslintrc@3.3.5)(@types/eslint@9.6.1)(typescript@5.9.3) + office-addin-manifest: + specifier: ^1.13.6 + version: 1.13.6 + office-addin-prettier-config: + specifier: ^1.2.1 + version: 1.2.1 + os-browserify: + specifier: ^0.3.0 + version: 0.3.0 + process: + specifier: ^0.11.10 + version: 0.11.10 + source-map-loader: + specifier: ^5.0.0 + version: 5.0.0(webpack@5.105.4) + webpack: + specifier: ^5.95.0 + version: 5.105.4(esbuild@0.27.7)(webpack-cli@5.1.4) + webpack-cli: + specifier: ^5.1.4 + version: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.105.4) + webpack-dev-server: + specifier: 5.1.0 + version: 5.1.0(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.105.4) + + demos/editor/integrations/word-addin/server: + dependencies: + '@google-cloud/storage': + specifier: ^7.16.0 + version: 7.19.0 + busboy: + specifier: 0.3.0 + version: 0.3.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.22.1 + express-oauth2-jwt-bearer: + specifier: ^1.6.0 + version: 1.7.4 + jsdom: + specifier: 27.3.0 + version: 27.3.0(canvas@3.2.3) + multer: + specifier: ^2.0.0 + version: 2.1.1 + prosemirror-state: + specifier: ^1.4.3 + version: 1.4.4 + superdoc: + specifier: workspace:* + version: link:../../../../../packages/superdoc + ws: + specifier: ^8.18.0 + version: 8.20.0 + devDependencies: + nodemon: + specifier: ^3.1.9 + version: 3.1.14 + + demos/editor/superdoc/docx-from-html: + dependencies: + superdoc: + specifier: workspace:* + version: link:../../../../packages/superdoc + vue: + specifier: 3.5.32 + version: 3.5.32(typescript@5.9.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.2 + version: 5.2.4(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + demos/fields: dependencies: superdoc: @@ -1255,149 +1394,6 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - demos/word-addin: - dependencies: - '@google-cloud/storage': - specifier: ^7.13.0 - version: 7.19.0 - auth0-js: - specifier: ^9.28.0 - version: 9.32.0 - core-js: - specifier: ^3.36.0 - version: 3.49.0 - cors: - specifier: ^2.8.5 - version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^5.1.0 - version: 5.2.1 - express-oauth2-jwt-bearer: - specifier: ^1.6.0 - version: 1.7.4 - jsdom: - specifier: 27.3.0 - version: 27.3.0(canvas@3.2.3) - regenerator-runtime: - specifier: ^0.14.1 - version: 0.14.1 - ws: - specifier: ^8.18.0 - version: 8.20.0 - devDependencies: - '@babel/core': - specifier: ^7.24.0 - version: 7.29.0 - '@babel/preset-env': - specifier: ^7.25.4 - version: 7.29.2(@babel/core@7.29.0) - '@types/office-js': - specifier: ^1.0.377 - version: 1.0.585 - '@types/office-runtime': - specifier: ^1.0.35 - version: 1.0.36 - acorn: - specifier: ^8.11.3 - version: 8.16.0 - babel-loader: - specifier: ^9.1.3 - version: 9.2.1(@babel/core@7.29.0)(webpack@5.105.4) - copy-webpack-plugin: - specifier: ^12.0.2 - version: 12.0.2(webpack@5.105.4) - eslint-plugin-office-addins: - specifier: ^3.0.2 - version: 3.0.3(@eslint/eslintrc@3.3.5)(@types/eslint@9.6.1) - file-loader: - specifier: ^6.2.0 - version: 6.2.0(webpack@5.105.4) - html-loader: - specifier: ^5.0.0 - version: 5.1.0(webpack@5.105.4) - html-webpack-plugin: - specifier: ^5.6.3 - version: 5.6.6(webpack@5.105.4) - office-addin-cli: - specifier: ^1.6.5 - version: 1.6.5 - office-addin-debugging: - specifier: ^5.1.6 - version: 5.1.6(@microsoft/teamsapp-cli@3.0.2) - office-addin-dev-certs: - specifier: ^1.13.5 - version: 1.13.5 - office-addin-lint: - specifier: ^2.3.5 - version: 2.3.5(@eslint/eslintrc@3.3.5)(@types/eslint@9.6.1)(typescript@5.9.3) - office-addin-manifest: - specifier: ^1.13.6 - version: 1.13.6 - office-addin-prettier-config: - specifier: ^1.2.1 - version: 1.2.1 - os-browserify: - specifier: ^0.3.0 - version: 0.3.0 - process: - specifier: ^0.11.10 - version: 0.11.10 - source-map-loader: - specifier: ^5.0.0 - version: 5.0.0(webpack@5.105.4) - webpack: - specifier: ^5.95.0 - version: 5.105.4(esbuild@0.27.7)(webpack-cli@5.1.4) - webpack-cli: - specifier: ^5.1.4 - version: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.105.4) - webpack-dev-server: - specifier: 5.1.0 - version: 5.1.0(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.105.4) - - demos/word-addin/server: - dependencies: - '@google-cloud/storage': - specifier: ^7.16.0 - version: 7.19.0 - busboy: - specifier: 0.3.0 - version: 0.3.0 - cors: - specifier: ^2.8.5 - version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^4.21.2 - version: 4.22.1 - express-oauth2-jwt-bearer: - specifier: ^1.6.0 - version: 1.7.4 - jsdom: - specifier: 27.3.0 - version: 27.3.0(canvas@3.2.3) - multer: - specifier: ^2.0.0 - version: 2.1.1 - prosemirror-state: - specifier: ^1.4.3 - version: 1.4.4 - superdoc: - specifier: workspace:* - version: link:../../../packages/superdoc - ws: - specifier: ^8.18.0 - version: 8.20.0 - devDependencies: - nodemon: - specifier: ^3.1.9 - version: 3.1.14 - evals: devDependencies: '@anthropic-ai/claude-agent-sdk': @@ -1724,6 +1720,19 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + examples/document-api/metadata-anchors: + dependencies: + superdoc: + specifier: workspace:* + version: link:../../../packages/superdoc + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + examples/document-engine/ai-redlining: dependencies: superdoc: @@ -2268,6 +2277,25 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + examples/getting-started/solid: + dependencies: + solid-js: + specifier: ^1.9.5 + version: 1.9.13 + superdoc: + specifier: workspace:* + version: link:../../../packages/superdoc + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(@testing-library/jest-dom@6.9.1)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(solid-js@1.9.13) + examples/getting-started/vanilla: dependencies: superdoc: @@ -3303,6 +3331,16 @@ importers: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + tests/document-api-smoke: + dependencies: + '@superdoc-dev/sdk': + specifier: workspace:* + version: link:../../packages/sdk/langs/node + devDependencies: + vitest: + specifier: 'catalog:' + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + tests/visual: dependencies: superdoc: @@ -4054,6 +4092,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -12095,6 +12137,11 @@ packages: '@babel/core': ^7.12.0 webpack: '>=5' + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -12119,6 +12166,15 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -15299,6 +15355,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -15991,6 +16050,10 @@ packages: is-what@3.14.1: resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -16839,6 +16902,7 @@ packages: lucide-svelte@0.475.0: resolution: {integrity: sha512-N5+hFTPHaZe9HhqJDxxxODfYuOmI6v+JIowzERcea/uxytN/JZlehVTcINBNp8wMo7l6ov1Jf5srrDbkI/WsJg==} + deprecated: Package deprecated. Please use @lucide/svelte instead. peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 @@ -17077,6 +17141,10 @@ packages: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-deep@3.0.3: resolution: {integrity: sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==} engines: {node: '>=0.10.0'} @@ -20169,6 +20237,7 @@ packages: rolldown-vite@7.3.1: resolution: {integrity: sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==} engines: {node: ^20.19.0 || >=22.12.0} + deprecated: Use this package to migrate from Vite 7 to Vite 8. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 @@ -20479,6 +20548,12 @@ packages: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.1: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} @@ -20720,6 +20795,14 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.13: + resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + sonic-boom@3.8.1: resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} @@ -22273,6 +22356,16 @@ packages: peerDependencies: vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite-plugin-solid@2.11.12: + resolution: {integrity: sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite-plugin-vue-devtools@7.7.9: resolution: {integrity: sha512-08DvePf663SxqLFJeMVNW537zzVyakp9KIrI2K7lwgaTqA5R/ydN/N2K8dgZO34tg/Qmw0ch84fOKoBtCEdcGg==} engines: {node: '>=v14.21.3'} @@ -24517,6 +24610,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -26406,16 +26503,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26441,13 +26528,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/confirm@5.1.21(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26462,19 +26542,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/core@10.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/core@10.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26541,14 +26608,6 @@ snapshots: chalk: 4.1.2 external-editor: 3.1.0 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26572,14 +26631,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26588,13 +26639,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -26619,13 +26663,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/input@4.3.1(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/input@4.3.1(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26640,13 +26677,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/number@3.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26661,14 +26691,6 @@ snapshots: ansi-escapes: 4.3.2 chalk: 4.1.2 - '@inquirer/password@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26689,21 +26711,6 @@ snapshots: '@inquirer/rawlist': 1.2.16 '@inquirer/select': 1.3.3 - '@inquirer/prompts@7.10.1(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -26719,20 +26726,20 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': + '@inquirer/prompts@7.9.0(@types/node@25.6.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) + '@inquirer/confirm': 5.1.21(@types/node@25.6.0) + '@inquirer/editor': 4.2.23(@types/node@25.6.0) + '@inquirer/expand': 4.0.23(@types/node@25.6.0) + '@inquirer/input': 4.3.1(@types/node@25.6.0) + '@inquirer/number': 3.0.23(@types/node@25.6.0) + '@inquirer/password': 4.0.23(@types/node@25.6.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) + '@inquirer/search': 3.2.2(@types/node@25.6.0) + '@inquirer/select': 4.4.2(@types/node@25.6.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.6.0 '@inquirer/rawlist@1.2.16': dependencies: @@ -26740,14 +26747,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26756,15 +26755,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/search@3.2.2(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26782,16 +26772,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/select@4.4.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/select@4.4.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26815,10 +26795,6 @@ snapshots: dependencies: mute-stream: 1.0.0 - '@inquirer/type@3.0.10(@types/node@22.19.2)': - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/type@3.0.10(@types/node@25.6.0)': optionalDependencies: '@types/node': 25.6.0 @@ -27383,11 +27359,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@25.6.0) '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27398,7 +27374,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@25.6.0) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 open: 8.4.2 @@ -27430,7 +27406,7 @@ snapshots: - utf-8-validate - yaml - '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@asyncapi/parser': 3.4.0 '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27470,7 +27446,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.1 remark-stringify: 11.0.0 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) unified: 11.0.5 unist-builder: 4.0.0 unist-util-map: 4.0.0 @@ -27554,13 +27530,13 @@ snapshots: - typescript - yaml - '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/models': 0.0.296 '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 @@ -27705,9 +27681,9 @@ snapshots: - utf-8-validate - yaml - '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 fs-extra: 11.1.1 hast-util-to-mdast: 10.1.0 @@ -34192,7 +34168,7 @@ snapshots: graceful-fs: 4.2.11 is-stream: 2.0.1 lazystream: 1.0.1 - lodash: 4.17.23 + lodash: 4.18.1 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -34547,6 +34523,15 @@ snapshots: schema-utils: 4.3.3 webpack: 5.105.4(esbuild@0.27.7)(webpack-cli@5.1.4) + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + html-entities: 2.3.3 + parse5: 7.3.0 + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.2 @@ -34585,6 +34570,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-preset-solid@1.9.12(@babel/core@7.29.0)(solid-js@1.9.13): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.0) + optionalDependencies: + solid-js: 1.9.13 + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -38651,6 +38643,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.3.3: {} + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -38691,7 +38685,7 @@ snapshots: dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 - lodash: 4.17.23 + lodash: 4.18.1 pretty-error: 4.0.0 tapable: 2.3.2 optionalDependencies: @@ -38702,7 +38696,7 @@ snapshots: dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 - lodash: 4.17.23 + lodash: 4.18.1 pretty-error: 4.0.0 tapable: 2.3.2 optionalDependencies: @@ -39069,12 +39063,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@25.6.0): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.10.1(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/prompts': 7.10.1(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + '@types/node': 25.6.0 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -39403,6 +39397,8 @@ snapshots: is-what@3.14.1: {} + is-what@4.1.16: {} + is-what@5.5.0: {} is-windows@1.0.2: {} @@ -40790,6 +40786,10 @@ snapshots: meow@13.2.0: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-deep@3.0.3: dependencies: arr-union: 3.1.0 @@ -41444,9 +41444,9 @@ snapshots: dependencies: minipass: 7.1.3 - mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: - '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -43407,13 +43407,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.3 optionalDependencies: postcss: 8.5.10 - ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: @@ -43693,7 +43693,7 @@ snapshots: pretty-error@4.0.0: dependencies: - lodash: 4.17.23 + lodash: 4.18.1 renderkid: 3.0.0 pretty-format@27.5.1: @@ -45625,6 +45625,10 @@ snapshots: serialize-javascript@7.0.5: {} + seroval-plugins@1.5.4(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + seroval@1.5.1: {} serve-handler@6.1.7: @@ -46044,6 +46048,21 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 + solid-js@1.9.13: + dependencies: + csstype: 3.2.3 + seroval: 1.5.1 + seroval-plugins: 1.5.4(seroval@1.5.1) + + solid-refresh@0.6.3(solid-js@1.9.13): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/types': 7.29.0 + solid-js: 1.9.13 + transitivePeerDependencies: + - supports-color + sonic-boom@3.8.1: dependencies: atomic-sleep: 1.0.0 @@ -46565,7 +46584,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -46584,7 +46603,7 @@ snapshots: postcss: 8.5.10 postcss-import: 15.1.0(postcss@8.5.10) postcss-js: 4.1.0(postcss@8.5.10) - postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.10) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -46950,14 +46969,14 @@ snapshots: '@swc/core': 1.15.21 optional: true - ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.2 + '@types/node': 25.6.0 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -48094,6 +48113,21 @@ snapshots: transitivePeerDependencies: - rollup + vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(solid-js@1.9.13): + dependencies: + '@babel/core': 7.29.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.0)(solid-js@1.9.13) + merge-anything: 5.1.7 + solid-js: 1.9.13 + solid-refresh: 0.6.3(solid-js@1.9.13) + vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + '@testing-library/jest-dom': 6.9.1 + transitivePeerDependencies: + - supports-color + vite-plugin-vue-devtools@7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue/devtools-core': 7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aab3cadaac..c690d51fb5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - tests/visual - tests/behavior - tests/doc-api-stories + - tests/document-api-smoke - apps/* - apps/cli/platforms/* - packages/**/* @@ -15,6 +16,8 @@ packages: - examples/*/*/*/*/* - demos/* - demos/*/* + - demos/*/*/* + - demos/*/*/*/* - evals catalog: diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 8fce550f0f..d0ab0ffd91 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -580,6 +580,7 @@ test('release-state probes wrap fetch in bounded retry to absorb transient blips test('docs promotion is keyed to a real superdoc tag from the orchestrator run', async () => { const promoteWorkflow = await readRepoFile('.github/workflows/promote-stable-docs.yml'); + const workflowRunBlock = promoteWorkflow.match(/workflow_run:[\s\S]*?workflow_dispatch:/)?.[0] ?? ''; assert.ok( promoteWorkflow.includes('workflow_run:'), '.github/workflows/promote-stable-docs.yml: must trigger on workflow_run completion', @@ -589,7 +590,7 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', '.github/workflows/promote-stable-docs.yml: must trigger off the stable orchestrator workflow', ); assert.equal( - /"๐Ÿ“ฆ Release CLI"|"๐Ÿ“ฆ Release SDK"|"๐Ÿ“ฆ Release MCP"|"๐Ÿ“ฆ Release react"|"๐Ÿ“ฆ Release esign"|"๐Ÿ“ฆ Release template-builder"|"๐Ÿ“ฆ Release vscode-ext"/.test(promoteWorkflow), + /"๐Ÿ“ฆ Release CLI"|"๐Ÿ“ฆ Release SDK"|"๐Ÿ“ฆ Release MCP"|"๐Ÿ“ฆ Release react"|"๐Ÿ“ฆ Release esign"|"๐Ÿ“ฆ Release template-builder"|"๐Ÿ“ฆ Release vscode-ext"/.test(workflowRunBlock), false, '.github/workflows/promote-stable-docs.yml: must trigger only off the orchestrator, not per-package workflows', ); @@ -604,6 +605,12 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', promoteWorkflow.includes("github.event.workflow_run.head_branch == 'stable'"), '.github/workflows/promote-stable-docs.yml: must scope promotion to stable', ); + assert.ok( + promoteWorkflow.includes('Wait for stable release lane to drain') && + promoteWorkflow.includes('gh run list') && + promoteWorkflow.includes('"๐Ÿ“ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐Ÿ“ฆ Release esign" or .name == "๐Ÿ“ฆ Release template-builder"'), + '.github/workflows/promote-stable-docs.yml: must wait for the stable release lane to drain before inspecting origin/stable', + ); assert.ok( promoteWorkflow.includes("git tag --merged origin/stable --list 'v[0-9]*'") && promoteWorkflow.includes('git tag --merged "${HEAD_SHA}" --list'), @@ -621,6 +628,14 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run', promoteWorkflow.includes('refs/heads/docs-stable'), '.github/workflows/promote-stable-docs.yml: must push to docs-stable', ); + assert.ok( + promoteWorkflow.includes('git log --oneline origin/stable..origin/docs-stable -- apps/docs/'), + '.github/workflows/promote-stable-docs.yml: must refuse to overwrite docs commits that exist only on docs-stable', + ); + assert.ok( + promoteWorkflow.includes('git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable"'), + '.github/workflows/promote-stable-docs.yml: must use force-with-lease when promoting the docs-stable pointer', + ); }); test('docs promotion supports manual workflow_dispatch with optional sha input', async () => { @@ -643,6 +658,18 @@ test('docs promotion supports manual workflow_dispatch with optional sha input', /Push docs-stable \(manual\)[\s\S]*if:\s*github\.event_name == 'workflow_dispatch'/.test(promoteWorkflow), '.github/workflows/promote-stable-docs.yml: manual push step must gate on workflow_dispatch only, not on detect.outputs', ); + assert.ok( + /Push docs-stable \(manual\)[\s\S]*git log --oneline "\$\{target\}"\.\.origin\/docs-stable -- apps\/docs\//.test( + promoteWorkflow, + ), + '.github/workflows/promote-stable-docs.yml: manual promotion must guard against overwriting docs commits unique to docs-stable', + ); + assert.ok( + /Push docs-stable \(manual\)[\s\S]*git push --force-with-lease=refs\/heads\/docs-stable:"\$\{expected\}" origin "\$\{target\}:refs\/heads\/docs-stable"/.test( + promoteWorkflow, + ), + '.github/workflows/promote-stable-docs.yml: manual promotion must use force-with-lease', + ); }); test('stable release workflows and commit filters include shared workspace coverage', async () => { diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs new file mode 100755 index 0000000000..3477f50169 --- /dev/null +++ b/scripts/check-public-contract.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Single command to validate the published superdoc package's public + * TypeScript surface end-to-end. + * + * Stages: + * 1. build:superdoc - vite build + the postbuild validator chain + * (check-tsconfig-type-surface, ensure-types, + * audit-bundle, audit-declarations, + * check-export-coverage, verify-public-facade-emit, + * report-declaration-reachability). + * Skipped when `--skip-build` is passed (CI calls + * `pnpm run build` separately in its own step). + * 2. typecheck-matrix - packs superdoc + installs the tarball into + * tests/consumer-typecheck/node_modules/, then + * runs every consumer scenario. + * 3. deep-type-audit - strict gate on the supported-root public + * surface (must be 0 findings). Reuses the + * install that stage 2 produced (no `--pack`). + * + * Matrix runs BEFORE audit on purpose: matrix packs + installs the + * tarball once, and the audit then reuses that install. Without this + * order the audit would `--pack` separately and double the work. + * + * Local usage: + * pnpm check:public-contract + * + * CI usage (Build step already ran): + * pnpm check:public-contract --skip-build + * + * SD-3256 Phase 1 (initial wrapper) / SD-673 Phase 1 (CI wiring). + */ + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const flags = new Set(process.argv.slice(2)); +const skipBuild = flags.has('--skip-build'); + +const stages = [ + { + name: 'build:superdoc', + cwd: REPO_ROOT, + cmd: 'pnpm', + args: ['run', 'build:superdoc'], + blurb: + 'Build dist + run postbuild validators (audit-bundle, audit-declarations, ' + + 'check-export-coverage, verify-public-facade-emit, ensure-types, ...).', + skipIf: skipBuild, + skipReason: '--skip-build passed; CI Build step already ran this', + }, + { + name: 'typecheck-matrix', + cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), + cmd: 'node', + args: ['typecheck-matrix.mjs'], + blurb: + 'Packs superdoc + installs the tarball into the consumer fixture, ' + + 'then runs every typecheck scenario.', + }, + { + name: 'deep-type-audit --strict-supported-root', + cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), + cmd: 'node', + args: ['deep-type-audit.mjs', '--strict-supported-root'], + blurb: + 'Strict gate on the supported-root public surface (must be 0 findings). ' + + 'Reuses the install produced by typecheck-matrix.', + }, +]; + +const HR = '='.repeat(72); +const start = Date.now(); + +let failed = null; +let ranCount = 0; +for (const [i, s] of stages.entries()) { + console.log(''); + console.log(HR); + console.log(`[${i + 1}/${stages.length}] ${s.name}`); + if (s.skipIf) { + console.log(`SKIP: ${s.skipReason}`); + console.log(HR); + continue; + } + console.log(s.blurb); + console.log(HR); + const result = spawnSync(s.cmd, s.args, { cwd: s.cwd, stdio: 'inherit' }); + ranCount += 1; + if (result.status !== 0) { + failed = { stage: s.name, status: result.status ?? 1 }; + break; + } +} + +const elapsed = ((Date.now() - start) / 1000).toFixed(1); +console.log(''); +console.log(HR); +if (failed) { + console.log(`FAIL: stage "${failed.stage}" exited ${failed.status} (after ${elapsed}s)`); + console.log(''); + console.log('Re-run the failing stage directly to iterate:'); + const failedStage = stages.find((s) => s.name === failed.stage); + console.log(` cd ${failedStage.cwd}`); + console.log(` ${failedStage.cmd} ${failedStage.args.join(' ')}`); + process.exit(failed.status); +} else { + const skipped = stages.length - ranCount; + const ranLabel = skipped > 0 ? `${ranCount} ran, ${skipped} skipped` : `${ranCount} stages`; + console.log(`PASS: ${ranLabel}, ${elapsed}s`); +} diff --git a/scripts/report-public-contract.mjs b/scripts/report-public-contract.mjs new file mode 100644 index 0000000000..506f108760 --- /dev/null +++ b/scripts/report-public-contract.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * SD-3256 Phase 2: print the public-contract tier metadata as a + * human-readable report. Read-only; no validation, no enforcement. + * + * Source of truth: `packages/superdoc/scripts/type-surface.config.cjs` + * (the `publicContract` export). Adding a new public subpath without + * adding it there means it is "internal" by definition of this report. + * + * Cross-checks done by this script: + * 1. Every `package.json#exports` subpath has a `publicContract` + * entry. Missing entries are listed as MISSING. + * 2. Every `publicContract` subpath actually exists in `exports`. + * Stale entries are listed as STALE. + * + * Both cross-checks are reported but do NOT change exit code in this + * phase (Phase 2 is read-only by design). The script always exits 0 + * unless it cannot load the config or package.json. + * + * Usage: + * pnpm report:public-contract + * + * Tracking: SD-3256 Phase 2. + */ + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); + +const config = require(resolve(REPO_ROOT, 'packages/superdoc/scripts/type-surface.config.cjs')); +const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'packages/superdoc/package.json'), 'utf8')); + +const { publicContract } = config; +if (!publicContract) { + console.error('FAIL: publicContract not exported from type-surface.config.cjs'); + process.exit(1); +} + +const exportsMap = pkg.exports || {}; +const exportSubpaths = new Set(Object.keys(exportsMap)); + +const allContractEntries = [ + ...publicContract.supported, + ...publicContract.legacy, + ...publicContract.legacyRaw, + ...publicContract.asset, + ...publicContract.deprecated, +]; +const contractSubpaths = new Set(allContractEntries.map((e) => e.subpath)); + +const HR = '='.repeat(72); + +const printTier = (name, entries) => { + console.log(''); + console.log(`## ${name} (${entries.length})`); + if (entries.length === 0) { + console.log(' (none)'); + return; + } + for (const e of entries) { + console.log(` ${e.subpath.padEnd(28)} ${e.note || ''}`); + } +}; + +console.log(HR); +console.log('SuperDoc public type contract (SD-3256 Phase 2)'); +console.log(HR); +console.log(''); +console.log('Tier definitions live in:'); +console.log(' packages/superdoc/scripts/type-surface.config.cjs (publicContract)'); +console.log(''); +console.log(`Total exports in package.json: ${exportSubpaths.size}`); +console.log(`Total entries in publicContract: ${contractSubpaths.size}`); + +printTier('Supported', publicContract.supported); +printTier('Legacy (curated through src/public/legacy/**)', publicContract.legacy); +printTier('Legacy-raw (NOT yet curated; SD-3256 Phase 3 target)', publicContract.legacyRaw); +printTier('Asset (non-type)', publicContract.asset); +printTier('Deprecated', publicContract.deprecated); + +// Cross-check: missing (in exports but not in contract) +const missing = [...exportSubpaths].filter((s) => !contractSubpaths.has(s)); +// Cross-check: stale (in contract but not in exports) +const stale = [...contractSubpaths].filter((s) => !exportSubpaths.has(s)); + +console.log(''); +console.log(HR); +console.log('Cross-checks vs package.json#exports'); +console.log(HR); +if (missing.length === 0 && stale.length === 0) { + console.log('OK: every export has a contract entry and vice versa.'); +} else { + if (missing.length > 0) { + console.log(`MISSING (${missing.length}): in package.json#exports but not in publicContract:`); + for (const s of missing) console.log(` ${s}`); + } + if (stale.length > 0) { + console.log(`STALE (${stale.length}): in publicContract but not in package.json#exports:`); + for (const s of stale) console.log(` ${s}`); + } + console.log(''); + console.log('(Phase 2 is read-only; this does not fail CI. Phase 4 will gate.)'); +} + +console.log(''); diff --git a/scripts/test.mjs b/scripts/test.mjs index 68e5e617fc..e83cab9750 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -36,4 +36,14 @@ if (args.length === 0) { if (sdkScriptsExitCode !== 0) { process.exit(sdkScriptsExitCode); } + + const documentApiSmokeExitCode = run(pnpmCommand, [ + '--silent', + '--filter', + '@superdoc-testing/document-api-smoke', + 'test', + ]); + if (documentApiSmokeExitCode !== 0) { + process.exit(documentApiSmokeExitCode); + } } diff --git a/scripts/validate-examples-demos.ts b/scripts/validate-examples-demos.ts index e7cdc80b97..dcc2f2a674 100644 --- a/scripts/validate-examples-demos.ts +++ b/scripts/validate-examples-demos.ts @@ -74,6 +74,96 @@ const HARDCODED_PATH = /\/Users\/[a-z][a-zA-Z0-9_-]*\//g; type Issue = { file: string; line: number; kind: string; detail: string }; const issues: Issue[] = []; +// Manifest entry schema (SD-3217 round 4). Every entry in +// demos/manifest.json and examples/manifest.json must declare these. +const ALLOWED_SECTIONS = new Set(['editor', 'document-engine', 'ai', 'solutions', 'getting-started', 'advanced']); +const ALLOWED_KINDS = new Set(['minimal-example', 'integration-example', 'workflow-demo', 'reference-workspace']); +const ALLOWED_STATUSES = new Set(['active', 'hidden', 'archived', 'shim']); +const ALLOWED_SOURCE_KINDS = new Set(['local', 'external']); + +function validateManifest(manifestPath: string, relPath: string): void { + let entries: unknown; + try { + entries = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (err) { + issues.push({ file: relPath, line: 0, kind: 'invalid-json', detail: String(err).split('\n')[0] }); + return; + } + if (!Array.isArray(entries)) { + issues.push({ file: relPath, line: 0, kind: 'manifest-shape', detail: 'top-level must be an array' }); + return; + } + for (const [index, entry] of entries.entries()) { + if (typeof entry !== 'object' || entry === null) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-shape', + detail: `entry at index ${index} must be a JSON object with id and metadata fields`, + }); + continue; + } + const e = entry as Record; + const eid = typeof e.id === 'string' ? e.id : ''; + if (typeof e.section !== 'string' || !ALLOWED_SECTIONS.has(e.section)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: section missing or not one of ${[...ALLOWED_SECTIONS].join(', ')}`, + }); + } + if (typeof e.subsection !== 'string' || e.subsection.length === 0) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: subsection missing or empty (use 'core' if no natural subsection)`, + }); + } + if (typeof e.kind !== 'string' || !ALLOWED_KINDS.has(e.kind)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: kind missing or not one of ${[...ALLOWED_KINDS].join(', ')}`, + }); + } + if (typeof e.status !== 'string' || !ALLOWED_STATUSES.has(e.status)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: status missing or not one of ${[...ALLOWED_STATUSES].join(', ')}`, + }); + } + if (typeof e.sourceKind !== 'string' || !ALLOWED_SOURCE_KINDS.has(e.sourceKind)) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: sourceKind missing or not one of ${[...ALLOWED_SOURCE_KINDS].join(', ')}`, + }); + } + // sourceKind must agree with sourceRepo: monorepo entries are local, + // anything else is external. Cheap drift check. + if (typeof e.sourceRepo === 'string' && typeof e.sourceKind === 'string') { + const expectedKind = e.sourceRepo === 'superdoc-dev/superdoc' ? 'local' : 'external'; + if (e.sourceKind !== expectedKind) { + issues.push({ + file: relPath, + line: 0, + kind: 'manifest-schema', + detail: `${eid}: sourceKind '${e.sourceKind}' does not match sourceRepo '${e.sourceRepo}' (expected '${expectedKind}')`, + }); + } + } + } +} + +validateManifest(join(REPO_ROOT, 'demos/manifest.json'), 'demos/manifest.json'); +validateManifest(join(REPO_ROOT, 'examples/manifest.json'), 'examples/manifest.json'); + function walk(dir: string, files: string[] = []): string[] { let entries: string[]; try { diff --git a/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts index bfed3b0803..0190c9315f 100644 --- a/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts +++ b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts @@ -11,11 +11,7 @@ test.use({ config: { toolbar: 'full', showSelection: true } }); // explicit because it's the contract this spec asserts against. const HIDDEN_IDS = ['1001', '1004', '1005'] as const; const VISIBLE_IDS = ['1002', '1003'] as const; // boundingBox + omitted (default) -const HIDDEN_ALIAS_CANARIES = [ - 'HIDDEN_ALIAS_LEAK_CANARY', - 'HIDDEN_ALIAS_DOUBLE_A', - 'HIDDEN_ALIAS_DOUBLE_B', -] as const; +const HIDDEN_ALIAS_CANARIES = ['HIDDEN_ALIAS_LEAK_CANARY', 'HIDDEN_ALIAS_DOUBLE_A', 'HIDDEN_ALIAS_DOUBLE_B'] as const; const INLINE_SDT = '.superdoc-structured-content-inline'; const INLINE_LABEL = '.superdoc-structured-content-inline__label'; @@ -78,9 +74,7 @@ test.describe('inline SDT appearance=hidden (SD-3110)', () => { const layoutText = await superdoc.page.evaluate(() => { // .presentation-editor__pages is the painter-dom root; selection, // copy, and visual reads operate on it. - const root = - document.querySelector('.presentation-editor__pages') ?? - document.querySelector('.superdoc-layout'); + const root = document.querySelector('.presentation-editor__pages') ?? document.querySelector('.superdoc-layout'); return root?.textContent ?? ''; }); @@ -101,9 +95,7 @@ test.describe('inline SDT appearance=hidden (SD-3110)', () => { // is split across lines/fragments โ€” each fragment carries the same // data-sdt-id. Scope to the painter class and take `.first()`: the // CSS specificity bug is per-element, so a single wrapper is enough. - const wrapper = superdoc.page - .locator('.superdoc-structured-content-inline[data-sdt-id="1001"]') - .first(); + const wrapper = superdoc.page.locator('.superdoc-structured-content-inline[data-sdt-id="1001"]').first(); await wrapper.hover(); await superdoc.waitForStable(); diff --git a/tests/consumer-typecheck/check-all-public-types-fixture.mjs b/tests/consumer-typecheck/check-all-public-types-fixture.mjs new file mode 100644 index 0000000000..876b2c4aa7 --- /dev/null +++ b/tests/consumer-typecheck/check-all-public-types-fixture.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * SD-3213a replacement gate for the retired SD-2860 source-sync check. + * + * After the SD-3212 PR C root facade flip, the canonical root contract + * lives in `packages/superdoc/src/public/index.ts`, locked by + * `tests/consumer-typecheck/snapshots/superdoc-root-exports.json` and + * classified at + * `tests/consumer-typecheck/snapshots/superdoc-root-classification.json`. + * + * The SD-2842 matrix scenarios exercise `tests/consumer-typecheck/src/ + * all-public-types.ts` to catch type-only exports collapsing to `any`. + * Without a forcing function, a future PR can add a new type-only root + * export (update src/public/index.ts + classification + root snapshot) + * and pass all other gates while never adding an AssertNotAny + * assertion. The any-collapse coverage silently shrinks relative to the + * actual public type surface. + * + * This script closes that gap by deriving the expected assertion set + * from the classification artifact instead of the retired legacy typedef + * block in `packages/superdoc/src/index.js`: + * + * - Expected = classification rows where `inDts` is true and both + * runtime presence flags (`inEsm`, `inCjs`) are false. That is the + * set of type-only root exports โ€” irrespective of bucket. Type-only + * `legacy-root` and `internal-candidate` symbols stay covered because + * they are still reachable from the published root and consumers + * can still import them (until a future major removes them). + * - Actual = `const _real_X: AssertNotAny = ...` assertions found + * in `src/all-public-types.ts`. + * - Fail on missing OR extra; the failure message names each diff + * symbol and points contributors at the fix. + * + * Modes: + * node check-all-public-types-fixture.mjs (== --check) + * node check-all-public-types-fixture.mjs --check + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, join } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLASSIFICATION = resolve(HERE, 'snapshots/superdoc-root-classification.json'); +const FIXTURE = resolve(HERE, 'src/all-public-types.ts'); + +if (!existsSync(CLASSIFICATION)) { + console.error('[SD-3213a] Classification file not found:', CLASSIFICATION); + process.exit(2); +} +if (!existsSync(FIXTURE)) { + console.error('[SD-3213a] Fixture file not found:', FIXTURE); + process.exit(2); +} + +const classification = JSON.parse(readFileSync(CLASSIFICATION, 'utf8')); +const expected = new Set( + classification.rows + .filter((r) => r.inDts === true && r.inEsm === false && r.inCjs === false) + .map((r) => r.name), +); + +const fixture = readFileSync(FIXTURE, 'utf8'); +const actual = new Set(); +for (const m of fixture.matchAll(/^const _real_([A-Za-z][A-Za-z0-9_]*)\s*:/gm)) { + actual.add(m[1]); +} + +const missing = [...expected].filter((n) => !actual.has(n)).sort(); +const extra = [...actual].filter((n) => !expected.has(n)).sort(); + +console.log(`[SD-3213a] Expected type-only root exports: ${expected.size}`); +console.log(`[SD-3213a] Actual AssertNotAny assertions: ${actual.size}`); + +if (missing.length === 0 && extra.length === 0) { + console.log('[SD-3213a] OK โ€” all-public-types.ts covers every type-only root export.'); + process.exit(0); +} + +if (missing.length) { + console.error(''); + console.error(`[SD-3213a] FAIL โ€” ${missing.length} type-only root export(s) are missing AssertNotAny coverage:`); + for (const n of missing) console.error(` - ${n}`); + console.error(''); + console.error('Add a corresponding entry to tests/consumer-typecheck/src/all-public-types.ts:'); + console.error(" - import { } from 'superdoc'; (or import type { ... } with the others)"); + console.error(' - const _real_: AssertNotAny<> = true;'); +} + +if (extra.length) { + console.error(''); + console.error(`[SD-3213a] FAIL โ€” ${extra.length} assertion(s) in all-public-types.ts have no corresponding type-only root export in the classification:`); + for (const n of extra) console.error(` - ${n}`); + console.error(''); + console.error('Either the symbol was renamed/removed at root (update or remove the assertion),'); + console.error('or it is a runtime value rather than a type-only export (move to the appropriate runtime assertion).'); +} + +process.exit(1); diff --git a/tests/consumer-typecheck/check-public-types.mjs b/tests/consumer-typecheck/check-public-types.mjs deleted file mode 100644 index 5e174c676f..0000000000 --- a/tests/consumer-typecheck/check-public-types.mjs +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env node -/** - * SD-2860: keep `tests/consumer-typecheck/src/all-public-types.ts` in sync with - * the public type surface that `superdoc` actually exports. - * - * Without this, a developer can add a new `@typedef` line to - * `packages/superdoc/src/index.js` (or land a new public export some other - * way that flows through the typedef block) and the regression net does not - * cover the new type. The matrix passes, the type collapses to `any` for - * customers, and we find out from a customer report. - * - * The source of truth is the JSDoc `@typedef {import('...').} ` - * block in `packages/superdoc/src/index.js`. Each line declares one public - * type. The script reads that block, reads the assertion list in - * `all-public-types.ts`, and: - * - * - default mode (`--check`): exits non-zero if the two lists differ, with - * a clear message listing what is missing or extra and how to fix it. - * - `--write`: regenerates the test file from the source list. Fast path - * for a developer who added a new public export and just wants to wire - * up the assertion. - * - * The script is intentionally low-tech (regex on the source), not a TS - * compiler API call. The typedef block has a stable shape and adding more - * sources of truth (direct `export type` constructs, etc.) is a follow-up - * if/when they appear. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, '..', '..'); - -const SOURCE_FILE = path.join(repoRoot, 'packages/superdoc/src/index.js'); -const TEST_FILE = path.join(__dirname, 'src/all-public-types.ts'); - -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : 'check'; - -if (!fs.existsSync(SOURCE_FILE)) { - console.error(`[check-public-types] source file not found: ${SOURCE_FILE}`); - process.exit(1); -} -if (!fs.existsSync(TEST_FILE)) { - console.error(`[check-public-types] test file not found: ${TEST_FILE}`); - process.exit(1); -} - -// Parse the @typedef block. Each line looks like: -// * @typedef {import('@superdoc/super-editor').EditorState} EditorState -// We capture the trailing identifier (the typedef name). -const sourceContent = fs.readFileSync(SOURCE_FILE, 'utf8'); -const TYPEDEF_RE = /@typedef\s+\{import\(['"][^'"]+['"]\)\.[A-Za-z0-9_]+\}\s+([A-Za-z0-9_]+)/g; -const sourceTypes = new Set(); -for (const match of sourceContent.matchAll(TYPEDEF_RE)) { - sourceTypes.add(match[1]); -} - -// Parse the test file. Anchor on `const _real_` so doc-comment placeholders -// like the literal `_real_X` reference inside the leading comment block do -// not contaminate the list. -const testContent = fs.readFileSync(TEST_FILE, 'utf8'); -const ASSERTION_RE = /^const\s+_real_[A-Za-z0-9_]+:\s*AssertNotAny<([A-Za-z0-9_]+)>/gm; -const testTypes = new Set(); -for (const match of testContent.matchAll(ASSERTION_RE)) { - testTypes.add(match[1]); -} - -const missingFromTest = [...sourceTypes].filter((t) => !testTypes.has(t)).sort(); -const extraInTest = [...testTypes].filter((t) => !sourceTypes.has(t)).sort(); - -console.log('[check-public-types] superdoc public-type surface'); -console.log('='.repeat(72)); -console.log(`Source: ${path.relative(repoRoot, SOURCE_FILE)}`); -console.log(` ${sourceTypes.size} typedef${sourceTypes.size === 1 ? '' : 's'}`); -console.log(`Test: ${path.relative(repoRoot, TEST_FILE)}`); -console.log(` ${testTypes.size} assertion${testTypes.size === 1 ? '' : 's'}`); -console.log(); - -if (missingFromTest.length === 0 && extraInTest.length === 0) { - console.log('OK Test list matches the public-type surface.'); - // In `--write` mode, fall through to the regeneration block. The file may - // be in semantic sync (every typedef has an assertion) but stale on - // formatting or comments; `--write` is the explicit "force regenerate" - // path, not "regenerate only when names diverged." - if (mode !== 'write') { - process.exit(0); - } -} - -if (missingFromTest.length > 0) { - console.log(`FAIL ${missingFromTest.length} public type${missingFromTest.length === 1 ? '' : 's'} missing from the test:`); - for (const name of missingFromTest) { - console.log(` ${name}`); - } -} -if (extraInTest.length > 0) { - if (missingFromTest.length > 0) console.log(); - console.log(`FAIL ${extraInTest.length} type${extraInTest.length === 1 ? '' : 's'} in the test but not in the source typedef block:`); - for (const name of extraInTest) { - console.log(` ${name}`); - } - console.log(' Either add the missing @typedef line, or remove the assertion.'); -} - -if (mode !== 'write') { - console.log(); - console.log('Run with --write to regenerate the test file from the typedef block,'); - console.log('or add the missing assertions manually. See the script header for details.'); - process.exit(1); -} - -// `--write` mode: regenerate the test file from the source list. -const sortedNames = [...sourceTypes].sort(); - -// Preserve the file header and the IsAny / AssertNotAny helpers; rewrite the -// import block and the assertion list. Anchor on the existing structure. -const header = `/** - * Consumer typecheck: every public type from superdoc must resolve to - * a real interface, not collapse to \`any\`, and not be missing. - * - * Each \`AssertNotAny\` resolves to \`never\` when T is \`any\`, so the - * \`const _real_X: AssertNotAny = true\` lines fail to compile if X - * has collapsed. A missing export shows up as TS2305 on the import. - * - * THIS FILE IS GENERATED from the JSDoc @typedef block in - * packages/superdoc/src/index.js. Edit the typedef block (or run - * node tests/consumer-typecheck/check-public-types.mjs --write - * from the repo root, or \`npm run check:types:write\` from inside - * tests/consumer-typecheck) and commit both. SD-2860's check script enforces - * that the two stay in sync; a missing assertion fails CI with a message - * pointing at this script. - */ -import type { -${sortedNames.map((n) => ` ${n},`).join('\n')} -} from 'superdoc'; - -// Helper: IsAny resolves to \`true\` when T is \`any\`, otherwise false. -type IsAny = 0 extends 1 & T ? true : false; -type AssertNotAny = IsAny extends true ? never : true; - -// One assertion per type. If T is \`any\`, AssertNotAny is \`never\` and -// the line below fails to compile with "Type 'true' is not assignable -// to type 'never'". If T is real, it compiles silently. -${sortedNames.map((n) => `const _real_${n}: AssertNotAny<${n}> = true;`).join('\n')} -`; - -fs.writeFileSync(TEST_FILE, header); -console.log(); -console.log(`Wrote ${sortedNames.length} assertions to ${path.relative(repoRoot, TEST_FILE)}.`); -process.exit(0); diff --git a/tests/consumer-typecheck/check-root-classification-closure.mjs b/tests/consumer-typecheck/check-root-classification-closure.mjs new file mode 100644 index 0000000000..eb1ca40f5d --- /dev/null +++ b/tests/consumer-typecheck/check-root-classification-closure.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * SD-3212 A1b โ€” root classification closure gate. + * + * Reads tests/consumer-typecheck/snapshots/superdoc-root-classification.json + * and asserts: no `supported-root` or `legacy-root` exported root symbol + * references an `internal-candidate` root symbol in its public declared + * type. This catches the failure class where a public/legacy export + * depends on a supposedly-internal type โ€” exactly the inconsistency that + * produced the 31-failure dry-run in Phase 4a and that the dependency- + * closure rule in A1 is meant to prevent. + * + * Scope (intentionally narrow for v1, per SD-3212 plan): + * - Loads the emitted root .d.ts (via the packed-and-installed fixture). + * - For each supported-root and legacy-root exported root symbol, walks + * its declared type and collects the names of referenced root-exported + * types (bounded recursion with a visited set). + * - Fails on any reference whose name is classified internal-candidate. + * - Allows manual overrides for DOM globals, ProseMirror upstream + * types, generic utility shapes (anything not classified at root). + * + * Out of scope for v1: + * - Runtime implementation analysis. + * - Private field walks. + * - Cross-package type origins (we only assert closure within names + * that exist at root; non-root types are not subject to the gate). + * + * Usage: + * node check-root-classification-closure.mjs + * + * CI runs this after the consumer-typecheck matrix has packed and + * installed the fixture. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, join } from 'node:path'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../..'); +const CLASSIFICATION = resolve(HERE, 'snapshots/superdoc-root-classification.json'); +const FIXTURE_SUPERDOC = resolve(HERE, 'node_modules', 'superdoc'); + +if (!existsSync(FIXTURE_SUPERDOC)) { + console.error('[SD-3212 a1b] superdoc fixture is not installed.'); + console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (packs and installs).'); + process.exit(1); +} +if (!existsSync(CLASSIFICATION)) { + console.error('[SD-3212 a1b] Classification file not found:', CLASSIFICATION); + process.exit(1); +} + +const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); +let ts; +try { ts = req('typescript'); } catch { + ts = createRequire(join(HERE, 'package.json'))('typescript'); +} + +const classification = JSON.parse(readFileSync(CLASSIFICATION, 'utf8')); +const bucketByName = Object.fromEntries(classification.rows.map((r) => [r.name, r.bucket])); +const allRootNames = new Set(Object.keys(bucketByName)); +const internalCandidates = new Set(classification.rows.filter((r) => r.bucket === 'internal-candidate').map((r) => r.name)); + +// Manual overrides for known-acceptable references. Each entry MUST include +// a reason string explaining why the closure assertion is intentionally +// skipped for this symbol. Empty reason is a structural error and will +// abort. Use sparingly: the gate exists precisely to surface these. +// +// Adding an entry should land in its own PR with the rationale visible in +// the commit message and the reason string referenceable from grep. +// +// Shape: Map. +const OVERRIDES = new Map([ + // ['ExampleType', 'DOM global re-exposed via X; not a real classification leak (see SD-XXXX).'], +]); + +// Validate override shape at startup so we never accept an empty reason. +for (const [name, reason] of OVERRIDES) { + if (typeof reason !== 'string' || reason.trim().length < 20) { + console.error(`[SD-3212 a1b] OVERRIDES entry '${name}' must include a reason string of >= 20 chars.`); + process.exit(2); + } +} + +// Resolve the emitted root .d.ts path from the installed package.json#exports +const pkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); +const rootTypes = pkg.exports?.['.']?.types; +const rootDtsRel = typeof rootTypes === 'string' ? rootTypes : rootTypes?.import ?? rootTypes?.default; +if (!rootDtsRel) { + console.error('[SD-3212 a1b] Could not resolve root types path from installed package.json.'); + process.exit(1); +} +const rootDts = resolve(FIXTURE_SUPERDOC, rootDtsRel); +if (!existsSync(rootDts)) { + console.error('[SD-3212 a1b] Root .d.ts not found at:', rootDts); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// TS program +// --------------------------------------------------------------------------- +const program = ts.createProgram({ + rootNames: [rootDts], + options: { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + noEmit: true, + skipLibCheck: true, + allowJs: false, + declaration: false, + }, +}); +const checker = program.getTypeChecker(); +const sf = program.getSourceFile(rootDts); +if (!sf) { console.error('[SD-3212 a1b] Could not load root .d.ts as source file'); process.exit(1); } +const rootSymbol = checker.getSymbolAtLocation(sf) ?? sf.symbol; +if (!rootSymbol) { console.error('[SD-3212 a1b] Root module has no symbol'); process.exit(1); } +const rootExports = checker.getExportsOfModule(rootSymbol); + +// --------------------------------------------------------------------------- +// Walk a type and collect referenced root-symbol names (bounded recursion) +// --------------------------------------------------------------------------- +function collectRootReferences(type, visited = new Set(), depth = 0) { + const refs = new Set(); + if (!type) return refs; + // Bound the walk; 6 levels is enough to see properties of nested types. + if (depth > 6) return refs; + const typeId = type.id ?? null; + if (typeId != null) { + if (visited.has(typeId)) return refs; + visited.add(typeId); + } + // Symbol identity: if this type's symbol's name is a root export, record it + const sym = type.aliasSymbol ?? type.symbol; + if (sym) { + const symName = sym.getName(); + if (allRootNames.has(symName)) refs.add(symName); + } + // Walk union/intersection members + if (type.isUnionOrIntersection?.()) { + for (const sub of type.types || []) { + for (const r of collectRootReferences(sub, visited, depth + 1)) refs.add(r); + } + } + // Walk type arguments (e.g. Foo, Array) + const typeArgs = checker.getTypeArguments?.(type) ?? []; + for (const arg of typeArgs) { + for (const r of collectRootReferences(arg, visited, depth + 1)) refs.add(r); + } + // Walk apparent properties (object types, interfaces, classes) + const props = type.getProperties?.() ?? []; + for (const p of props) { + const pType = checker.getTypeOfSymbolAtLocation(p, sf); + for (const r of collectRootReferences(pType, visited, depth + 1)) refs.add(r); + } + // Walk call signatures (functions/methods) + const callSigs = checker.getSignaturesOfType?.(type, ts.SignatureKind.Call) ?? []; + for (const cs of callSigs) { + for (const param of cs.parameters || []) { + const pType = checker.getTypeOfSymbolAtLocation(param, sf); + for (const r of collectRootReferences(pType, visited, depth + 1)) refs.add(r); + } + const retType = cs.getReturnType?.(); + if (retType) for (const r of collectRootReferences(retType, visited, depth + 1)) refs.add(r); + } + // Walk construct signatures (class constructors) + const ctorSigs = checker.getSignaturesOfType?.(type, ts.SignatureKind.Construct) ?? []; + for (const cs of ctorSigs) { + for (const param of cs.parameters || []) { + const pType = checker.getTypeOfSymbolAtLocation(param, sf); + for (const r of collectRootReferences(pType, visited, depth + 1)) refs.add(r); + } + const retType = cs.getReturnType?.(); + if (retType) for (const r of collectRootReferences(retType, visited, depth + 1)) refs.add(r); + } + return refs; +} + +// --------------------------------------------------------------------------- +// Run the check +// --------------------------------------------------------------------------- +const violations = []; +let inspected = 0; +let skippedNotInClassification = 0; + +for (const exportSym of rootExports) { + const name = exportSym.getName(); + const bucket = bucketByName[name]; + if (!bucket) { skippedNotInClassification++; continue; } + if (bucket !== 'supported-root' && bucket !== 'legacy-root') continue; + inspected++; + const exportType = checker.getTypeOfSymbolAtLocation(exportSym, sf); + const refs = collectRootReferences(exportType); + // The export itself shows up in refs; exclude self. + refs.delete(name); + for (const refName of refs) { + if (internalCandidates.has(refName) && !OVERRIDES.has(refName)) { + violations.push({ exporter: name, exporterBucket: bucket, references: refName, referencesBucket: 'internal-candidate' }); + } + // Note overridden references for telemetry/visibility. + if (internalCandidates.has(refName) && OVERRIDES.has(refName)) { + console.log(`[SD-3212 a1b] override applied: ${name} (${bucket}) references ${refName} (internal-candidate). Reason: ${OVERRIDES.get(refName)}`); + } + } +} + +console.log('[SD-3212 a1b] Root exports inspected:', inspected); +console.log('[SD-3212 a1b] Skipped (not in classification snapshot):', skippedNotInClassification); +console.log('[SD-3212 a1b] Violations:', violations.length); +if (violations.length) { + for (const v of violations) { + console.error(` - ${v.exporter} (${v.exporterBucket}) references ${v.references} (internal-candidate)`); + } + console.error(''); + console.error('Closure-rule fix options:'); + console.error(' 1. Promote the referenced type from internal-candidate to legacy-root in superdoc-root-classification.json.'); + console.error(' 2. Tighten the exporter to not reference it.'); + console.error(' 3. If the reference is unavoidable and the type is genuinely DOM/upstream/utility-shaped, add a documented override in this script.'); + process.exit(1); +} +console.log('[SD-3212 a1b] OK โ€” no closure violations.'); diff --git a/tests/consumer-typecheck/deep-type-audit.README.md b/tests/consumer-typecheck/deep-type-audit.README.md index 69d8d6026b..651dfcaded 100644 --- a/tests/consumer-typecheck/deep-type-audit.README.md +++ b/tests/consumer-typecheck/deep-type-audit.README.md @@ -7,36 +7,41 @@ declarations. Tracked under SD-2977 as part of the "drain to fully compliant" umbrella SD-2976. -## Status: report-only inventory (gate deferred until SD-2966) +## Status: report-only inventory (gate deferred until audit is scoped to the facade) Today this audit runs in **inventory mode**: it walks the public surface, prints a tiered breakdown of findings, and always exits 0. It does NOT gate CI yet. -The gate behavior (failing CI on new findings) is intentionally deferred. -The current public surface is the *accidental declaration graph*: 1700+ -findings reachable through Pinia stores, EventEmitter generics, Vue SFC -component types, and other code that was never deliberately committed as -public API. Locking in an allowlist of that surface would be measuring -the wrong thing and would risk legitimizing internals as public API. +The facade landed in SD-3212 PR C (`packages/superdoc/src/public/index.ts` +is now the root contract, with `src/public/legacy/*` for legacy subpaths). +But the audit still walks every entry in `package.json#exports`, including +the broad legacy `./super-editor` raw export. Excluding it drops findings +from ~1,835 to ~1,510; the remainder is dominated by curated root exports +(SuperDoc, Editor, PresentationEditor, SuperToolbar) pulling deep +implementation types (Pinia stores, EventEmitter, editor/toolbar config). -SD-2966 defines the deliberate facade. Once it lands: +Gating on either number would recreate the prior allowlist problem +(see "Why no allowlist file is checked in (yet)" below). -1. Re-run this audit; the allowlist is much smaller (expected ~200-400 - entries against the facade, not 1700+ against the accidental graph). -2. Seed the allowlist via `node deep-type-audit.mjs --write`. -3. Add `--strict` to the CI invocation to make this a real gate. +The remaining work, tracked under SD-3213 follow-up: -Until then, the audit's value is the inventory: visible CI signal of how -much accidental surface is leaking, useful as evidence that SD-2966 is -worth doing. +1. Drain the residual `tier-4-public-contract` finding + (`SuperConverter[key: string]: any`) via SD-3235. SD-3213c reduced the + bucket from 16 findings to 1 by fully typing DocxZipper and partially + typing SuperConverter's constructor + named statics. +2. Improve audit attribution per entry/bucket so findings can be + distinguished as "supported-root leak" vs "legacy compat reach". +3. Scope the audit to curated facade entries (everything routing through + `src/public/**` except `./super-editor`), then make it strict. ## What "fully compliant" means (final state) The umbrella's success definition: -- deep audit allowlist reaches **0 owned findings against the deliberate - public facade defined by SD-2966** +- deep audit allowlist reaches **0 owned findings against the curated + public facade** (`src/public/**`, scoped to exclude broad legacy raw + exports like `./super-editor`) - the public facade is intentionally defined, not inherited from accidental barrel reachability - anything outside the facade is internal and is not part of the @@ -49,8 +54,9 @@ The umbrella's success definition: Two compliance classes, both required: -- **Type-quality compliance**: every reachable type *in the facade* is - real, not `any`. This audit (in `--strict` mode, post-facade) enforces it. +- **Type-quality compliance**: every reachable type *in the curated + facade* is real, not `any`. This audit (in `--strict` mode, scoped to + facade entries) will enforce it. - **Package-shape compliance**: manifest, exports, conditions, CDN fields are honest. SD-2978 (Packaging Honesty) owns this side. @@ -88,31 +94,144 @@ entries. That was reverted because: - A 17K-line public artifact creates noise in every PR diff - It would commit the team to typing internals (Pinia stores, EventEmitter, - Vue SFC types) that should be hidden via SD-2966's facade, not typed + Vue SFC types) that should be hidden behind the curated facade, not typed - It risks legitimizing accidental public surface as the type contract -The allowlist re-emerges after SD-2966 lands, scoped to the facade. Each -entry has a stable key (`kind|file|symbolPath|snippet`) so reformatting and -line shifts won't churn it. +The allowlist re-emerges once the audit is scoped to the curated facade +entries (SD-3213 follow-up). Each entry has a stable key +(`kind|file|symbolPath|snippet`) so reformatting and line shifts won't +churn it. + +## Attribution (SD-3213d) + +Each report now prints three breakdowns alongside the historical tier +and top-files tables, and writes a machine-readable JSON to +`tmp/deep-type-audit-attribution.json` (gitignored). The point is to +distinguish supported-root leaks from legacy compat reach from raw +`./super-editor` noise, so PR 3 can scope a strict gate to the curated +facade subset without guessing. + +The tables in a typical run look like: + +``` +[audit] By export entry (reachedFrom; one finding can count under several): + 1237 . + 728 ./super-editor + 79 ./ui/react + 70 ./headless-toolbar + 56 ./types + ... + +[audit] By root bucket (only for findings reached from root '.'): + 950 supported-root + 190 legacy-root + 97 internal-candidate + +[audit] Curated facade entries vs raw ./super-editor reach: + 1089 reached only from curated facade entries + 324 reached only from ./super-editor + 404 reached from both +``` + +How to read these: + +- **By entry** sums to more than the distinct-finding total because one + finding can be reachable from several public entries. The same row in + the deduped findings table contributes a count to each entry in its + `reachedFrom` set. +- **By root bucket** counts only findings whose `reachedFrom` includes + the root entry `.`, attributed via the top-level symbol in + `symbolPath` (e.g. `SuperDoc.provider.on(event)` โ†’ `SuperDoc` โ†’ + bucket from `snapshots/superdoc-root-classification.json`). If the + top-level parser fails or the symbol isn't in the classification, the + finding is counted as `unknown-root-export` so the parse failure rate + is visible. +- **Curated facade vs raw** partitions every distinct finding into one + of three buckets (sums to the distinct total). "Curated facade + entries" means every public entry except `./super-editor` โ€” i.e. the + set of entries routing through `src/public/**`. PR 3's strict scope + will live somewhere in this partition. + +The JSON artifact mirrors the text breakdown and also lists every +finding with its `reachedFrom` and `rootBuckets` sets, so downstream +tooling (e.g. PR 3's strict-scope selector) does not need to re-run the +walker. + +## Supported-root strict gate (SD-3213e) + +The first real public-contract no-new-any gate. Filters findings to the +subset whose `rootBuckets` includes `supported-root` (i.e. reached from +root entry `.` via an export that the SD-3212 classification labels as +supported public API) and compares them against a committed allowlist. + +- Allowlist file: `tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json`. +- **The allowlist is current known debt, not accepted API quality.** + Drain PRs reduce it; the gate fails on stale entries to force the + reduction to be recorded. +- Excludes `legacy-root`, `internal-candidate`, and raw `./super-editor` + reach. Each has its own drain story (legacy = compat, internal-candidate + = should be hidden, raw = redesign) and would obscure the + supported-root signal if mixed in. +- CI invokes one command (`--strict-supported-root`) that prints the + broad inventory AND runs the gate. No second workflow step. +- Top offender files + symbols are printed on every run so drain PRs + know where to start. + +```bash +# CI invocation: broad report + supported-root strict gate, one process. +node tests/consumer-typecheck/deep-type-audit.mjs --strict-supported-root + +# Seed or regenerate the supported-root allowlist (after a drain or +# when seeding for the first time). +node tests/consumer-typecheck/deep-type-audit.mjs --pack --write --strict-supported-root +``` + +## Gate map (which gate owns what) + +Multiple gates run against the public surface; each owns a distinct +failure class. Before adding a new gate, check whether one of these +already covers the concern. + +| Gate | Owns | +|---|---| +| `typecheck-matrix.mjs` | Consumer `tsc --noEmit` across module modes (Bundler / Node16 / NodeNext). Catches **resolution errors and missing exports**. | +| `deep-type-audit.mjs` | Recursive `any` detection on every type reachable from public exports. Owns the **supported-root strict gate** (`--strict-supported-root`). | +| `package-shape-gate.mjs` | `publint` + `attw --pack`. Catches **manifest issues**: condition ordering, masquerading ESM, missing CDN files, unpublished `source` paths. | +| `snapshot.mjs` | Drift detection on three export inventories (super-editor package keys, legacy subpath resolved exports, root 4-source inventory). Catches **silent surface growth**. | +| `check-root-classification-closure.mjs` | Dependency-closure rule: no `supported-root` or `legacy-root` export references an `internal-candidate` type in its declared public type. | +| `verify-public-facade-emit.cjs` | Curated `src/public/**` facade โ†” emitted `.d.ts` parity (symbol set, ESM/CJS parity, leak grep, command signatures). Runs at postbuild. | +| `audit-declarations.cjs` | Private workspace specifier leaks (`@superdoc/*`) and declaration-emit hygiene. Runs at postbuild. | + +Each gate runs once. PRs should extend an existing gate before adding +a new one โ€” see SD-3213e (PR which added the supported-root mode to +the existing `deep-type-audit.mjs` rather than introducing a new +script). ## Commands ```bash # Default: report-only inventory. Prints findings, always exits 0 -# (unless the script itself errors). Used by CI today. +# (unless the script itself errors). node tests/consumer-typecheck/deep-type-audit.mjs # Pack + install superdoc into the fixture, then run inventory node tests/consumer-typecheck/deep-type-audit.mjs --pack -# Strict mode: fails on findings if no allowlist exists, or on -# new/stale entries if an allowlist exists. NOT used in CI today; -# becomes the gate after SD-2966 defines the facade. +# Supported-root strict gate (CI). Prints broad inventory AND fails on +# new/stale entries in the supported-root allowlist. +node tests/consumer-typecheck/deep-type-audit.mjs --strict-supported-root + +# Broad strict mode: fails on findings against the broad allowlist. +# Not used in CI yet โ€” the broad allowlist would be ~1.8k entries +# dominated by legacy reach. Reserved for future work. node tests/consumer-typecheck/deep-type-audit.mjs --strict -# Seed or regenerate deep-type-audit.allowlist.json from current findings -# (intended for use after SD-2966 to baseline against the facade) +# Seed or regenerate the broad allowlist. node tests/consumer-typecheck/deep-type-audit.mjs --write + +# Seed or regenerate the supported-root allowlist (run after a drain +# PR to shrink the baseline). +node tests/consumer-typecheck/deep-type-audit.mjs --pack --write --strict-supported-root ``` ## Updating the allowlist @@ -154,9 +273,20 @@ default `auto-seeded from inventory` rationale. - **tier-3-helpers** (~61 entries): `trackChangesHelpers` and `fieldAnnotationHelpers`. JS files exported via the `helpers` namespace with no JSDoc. Best fix is probably JS to TS conversion. -- **tier-4-public-contract** (~2 entries): the curated `core/types/index.ts` - file. These are surgical fixes (`transaction: any` should import - `Transaction` from `prosemirror-state`, etc). +- **tier-4-public-contract**: currently **1 residual finding** + (`SuperConverter.d.ts`'s `[key: string]: any` catchall). Historically + included two classes of finding: (1) the hand-written shim files + `SuperConverter.d.ts` and `DocxZipper.d.ts` + (`constructor(...args: any[])`, `[key: string]: any`) โ€” partially + drained in SD-3213c (DocxZipper fully typed; SuperConverter constructor + + named statics typed); (2) curated entries in `core/types/index.ts` + like `transaction: any` that should import `Transaction` from + `prosemirror-state`. The residual `SuperConverter[key: string]: any` + cannot be removed without converting `SuperConverter.js` to TypeScript + (or formalizing a public/internal contract split) because internal + callers across `Editor.ts`, `PresentationEditor.ts`, + `HeaderFooterRegistry.ts`, and list-level helpers read dozens of + instance members through it. Tracked as a follow-up to SD-3213. - **tier-5-other**: catchall for anything that doesn't match the patterns above. @@ -165,11 +295,27 @@ default `auto-seeded from inventory` rationale. - `typecheck-matrix.mjs`: runs `tsc --noEmit` under N consumer tsconfigs. Catches *resolution* errors and *missing exports*. Doesn't see member-level `any`. -- `check-public-types.mjs`: verifies every public `@typedef` has an - assertion fixture. Asserts top-level type aliases aren't `any`. Doesn't - see member-level `any`. +- `snapshot.mjs --family root --check`: locks the root export inventory + across the four `package.json#exports` sources independently (types.import, + types.require, import, require). Each source has its own baseline (type + sources currently 200 names, runtime sources 41) and drift on any of the + four fails the gate. Cross-source mismatches (typed-only, runtime-only, + ESM vs CJS) are reported in the companion `.md` as evidence, not blockers. + CI calls the unified `snapshot.mjs --all --check` which runs this family + plus the `legacy` and `super-editor-package` families. +- `verify-public-facade-emit.cjs`: verifies the curated `src/public/**` + facade matches the emitted `.d.ts` (symbol set, ESM/CJS parity, leak + grep, command-signature probe). +- `check-root-classification-closure.mjs`: dependency-closure rule โ€” no + supported-root or legacy-root export references an internal-candidate + type in its public declared type. - **deep-type-audit.mjs (this)**: recursive walk; catches what the others - cannot. Together the three gates form the public-type contract guarantee. + cannot. + +(`check-public-types.mjs` was retired in SD-3213a after the root facade +flip โ€” the canonical root contract is now `packages/superdoc/src/public/index.ts` +plus the snapshot/facade-verifier gates above, not the legacy JSDoc +typedef block in `packages/superdoc/src/index.js`.) ## CI wiring diff --git a/tests/consumer-typecheck/deep-type-audit.mjs b/tests/consumer-typecheck/deep-type-audit.mjs index 313d2d5cac..78a756e596 100644 --- a/tests/consumer-typecheck/deep-type-audit.mjs +++ b/tests/consumer-typecheck/deep-type-audit.mjs @@ -17,10 +17,13 @@ * for visibility but does not block CI on its own. * * Run: - * node deep-type-audit.mjs # check against allowlist (CI mode) - * node deep-type-audit.mjs --pack # pack+install before checking - * node deep-type-audit.mjs --write # regenerate allowlist from current findings - * node deep-type-audit.mjs --report-only # print findings, never fail + * node deep-type-audit.mjs # report-only inventory (default) + * node deep-type-audit.mjs --pack # pack+install before running + * node deep-type-audit.mjs --strict-supported-root # CI gate (SD-3213e) + * node deep-type-audit.mjs --strict # broad strict mode (not in CI) + * node deep-type-audit.mjs --write # regenerate broad allowlist + * node deep-type-audit.mjs --pack --write --strict-supported-root + * # regenerate supported-root allowlist * * The fixture is intentionally outside the pnpm workspace so this audits * the customer-visible surface, not workspace symlinks. Install pattern @@ -44,13 +47,23 @@ const doWrite = args.has('--write'); // stale entries, compiler diagnostics, private specifier leaks). Without // it, the audit runs in inventory/reporting mode and always exits 0 // unless the script itself errors. Strict mode is intentionally NOT used -// in CI yet: it only becomes meaningful once SD-2966 defines the public -// facade and the allowlist is re-seeded against that smaller surface. +// in CI yet. The facade landed in SD-3212 PR C, but the audit still walks +// every entry in `package.json#exports`, including the broad legacy +// `./super-editor` surface. Until the audit is scoped to the curated +// facade entries (SD-3213 follow-up), strict-on-everything would gate +// on ~1.8k findings dominated by legacy reach. const doStrict = args.has('--strict'); +// SD-3213e: scoped strict gate. Filters findings to the supported-root +// subset (rootBuckets includes 'supported-root') and compares against +// `deep-type-audit.supported-root-allowlist.json`. Fails on new findings +// (regression) AND stale entries (a drain landed; allowlist must shrink). +// Orthogonal to `--strict` and `--pack`. Wired into CI as the first real +// no-new-any gate for the public contract. +const doStrictSupportedRoot = args.has('--strict-supported-root'); // Legacy alias: previous versions exposed `--report-only` as the way to // opt out of failing CI. The default is now report-only, so this flag // becomes a no-op (kept so existing invocations don't break). -const reportOnly = args.has('--report-only') || !doStrict; +const reportOnly = args.has('--report-only') || (!doStrict && !doStrictSupportedRoot); // -- Optional pack + install (must run BEFORE requiring typescript so a // fresh checkout where tests/consumer-typecheck/node_modules is empty can @@ -439,10 +452,51 @@ for (const root of roots) { function keyOf(f) { return [f.kind, f.file, f.symbolPath, f.snippet].join('|'); } +// Dedup preserves the existing stable key (kind|file|symbolPath|snippet) +// so allowlist identity does not churn. SD-3213d enriches each deduped +// row with attribution: `reachedFrom` is the set of package export entries +// (subpaths) through which the same finding was recorded. The first +// observation's other fields (line, symbolPath, etc.) win the row. const distinctFindings = new Map(); for (const f of findings) { const k = keyOf(f); - if (!distinctFindings.has(k)) distinctFindings.set(k, f); + if (!distinctFindings.has(k)) { + distinctFindings.set(k, { ...f, reachedFrom: new Set([f.subpath]) }); + } else { + distinctFindings.get(k).reachedFrom.add(f.subpath); + } +} + +// SD-3213d: attribute root-entry findings to their root-classification +// bucket (supported-root / legacy-root / internal-candidate). The +// classification artifact lives in-repo, not in the installed fixture. +// For findings not reached from the root entry, `rootBuckets` stays empty. +// For root-reached findings whose top-level symbol isn't in the +// classification, `rootBuckets` is ['unknown-root-export'] (counted +// explicitly so reviewers can see the parse failure rate). +const classificationPath = resolve(here, 'snapshots', 'superdoc-root-classification.json'); +const classification = existsSync(classificationPath) + ? JSON.parse(readFileSync(classificationPath, 'utf8')) + : { rows: [] }; +const rootBucketByName = new Map(classification.rows.map((r) => [r.name, r.bucket])); + +// symbolPath starts with the root export name followed by `.member`, +// `(param)`, `[]`, ``, `=>return`, `.`, etc. The top-level +// segment is everything before the first member/param/index/generic +// boundary. +function topLevelSymbolFrom(symbolPath) { + const m = symbolPath.match(/^([^.([<=]+)/); + return m ? m[1] : null; +} + +for (const f of distinctFindings.values()) { + const buckets = new Set(); + if (f.reachedFrom.has('.')) { + const top = topLevelSymbolFrom(f.symbolPath); + const bucket = top ? rootBucketByName.get(top) : null; + buckets.add(bucket ?? 'unknown-root-export'); + } + f.rootBuckets = buckets; } const allowlistPath = resolve(here, 'deep-type-audit.allowlist.json'); @@ -462,6 +516,35 @@ for (const [key, f] of distinctFindings) { } const staleAllowlistKeys = [...remainingAllowlist]; +// SD-3213e: supported-root scoped gate. The broad allowlist above tracks +// everything; this scoped one tracks ONLY findings reachable from root +// '.' whose top-level symbol is classified as `supported-root`. That is +// the subset that directly affects documented consumer IntelliSense. +// Legacy-root, internal-candidate, and raw `./super-editor` reach are +// intentionally excluded from this first strict gate; each has its own +// drain story (legacy = compat, internal-candidate = should be hidden, +// raw = redesign). +const supportedRootAllowlistPath = resolve(here, 'deep-type-audit.supported-root-allowlist.json'); +const supportedRootAllowlist = existsSync(supportedRootAllowlistPath) + ? JSON.parse(readFileSync(supportedRootAllowlistPath, 'utf8')) + : { version: 1, generatedAt: null, entries: [] }; +const supportedRootAllowlistByKey = new Map(supportedRootAllowlist.entries.map((e) => [e.key, e])); + +const supportedRootFindings = new Map(); +for (const [key, f] of distinctFindings) { + if (f.rootBuckets.has('supported-root')) supportedRootFindings.set(key, f); +} +const newSupportedRoot = []; +const remainingSupportedRoot = new Set(supportedRootAllowlistByKey.keys()); +for (const [key, f] of supportedRootFindings) { + if (supportedRootAllowlistByKey.has(key)) { + remainingSupportedRoot.delete(key); + } else { + newSupportedRoot.push({ key, ...f }); + } +} +const staleSupportedRootKeys = [...remainingSupportedRoot]; + // -- Owner classification helper (used when seeding the allowlist) --------- function classifyOwner(f) { if (f.owner === 'upstream') return 'upstream'; @@ -470,15 +553,43 @@ function classifyOwner(f) { if (f.file.includes('trackChangesHelpers') || f.file.includes('fieldAnnotationHelpers')) return 'tier-3-helpers'; if (f.file.endsWith('core/types/index.d.ts')) return 'tier-4-public-contract'; // SuperConverter + DocxZipper expose `[key: string]: any` and - // `constructor(...args: any[])`. SD-2966's done-when criteria explicitly - // call these out as accidentally-public; group with tier-4 so the - // facade work owns the fix. + // `constructor(...args: any[])`. Both are classified as `legacy-root` + // in superdoc-root-classification.json (Decision 1 of + // package-boundaries.md); group with tier-4 so the public-contract + // drain work owns the fix. if (f.file.endsWith('SuperConverter.d.ts') || f.file.endsWith('DocxZipper.d.ts')) return 'tier-4-public-contract'; return 'tier-5-other'; } // -- Write mode ----------------------------------------------------------- +// `--write` interacts with the scope flag: with `--strict-supported-root`, +// it regenerates only the supported-root allowlist; otherwise it +// regenerates the broad allowlist. if (doWrite) { + if (doStrictSupportedRoot) { + const sorted = [...supportedRootFindings.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const next = { + version: 1, + scope: 'supported-root', + generatedAt: new Date().toISOString(), + entries: sorted.map(([key, f]) => { + const existing = supportedRootAllowlistByKey.get(key); + return { + key, + kind: f.kind, + symbolPath: f.symbolPath, + file: f.file, + line: f.line, + snippet: f.snippet, + owner: existing?.owner ?? classifyOwner(f), + rationale: existing?.rationale ?? `auto-seeded from inventory (supported-root scope)`, + }; + }), + }; + writeFileSync(supportedRootAllowlistPath, JSON.stringify(next, null, 2) + '\n'); + console.log(`[audit] Wrote supported-root allowlist with ${next.entries.length} entries to ${relative(repoRoot, supportedRootAllowlistPath)}`); + process.exit(0); + } const sorted = [...distinctFindings.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const next = { version: 1, @@ -531,6 +642,73 @@ for (const [k, v] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1]).slic console.log(` ${v.toString().padStart(5)} ${k}`); } +// SD-3213d attribution tables. The point of these breakdowns is to +// distinguish supported-root leaks from legacy compat reach from raw +// ./super-editor noise, so PR 3 can scope the strict gate to the +// curated facade subset without guessing. +const entryCounts = {}; +const rootBucketCounts = {}; +let curatedOnly = 0; +let rawOnly = 0; +let both = 0; +for (const f of tieredFindings) { + for (const e of f.reachedFrom) entryCounts[e] = (entryCounts[e] ?? 0) + 1; + for (const b of f.rootBuckets) rootBucketCounts[b] = (rootBucketCounts[b] ?? 0) + 1; + const reachesCurated = [...f.reachedFrom].some((e) => e !== './super-editor'); + const reachesRaw = f.reachedFrom.has('./super-editor'); + if (reachesCurated && reachesRaw) both++; + else if (reachesCurated) curatedOnly++; + else if (reachesRaw) rawOnly++; +} +console.log(``); +console.log(`[audit] By export entry (reachedFrom; one finding can count under several):`); +for (const [k, v] of Object.entries(entryCounts).sort((a, b) => b[1] - a[1])) { + console.log(` ${v.toString().padStart(5)} ${k}`); +} +console.log(``); +console.log(`[audit] By root bucket (only for findings reached from root '.'):`); +for (const [k, v] of Object.entries(rootBucketCounts).sort((a, b) => b[1] - a[1])) { + console.log(` ${v.toString().padStart(5)} ${k}`); +} +console.log(``); +console.log(`[audit] Curated facade entries vs raw ./super-editor reach:`); +console.log(` ${curatedOnly.toString().padStart(5)} reached only from curated facade entries`); +console.log(` ${rawOnly.toString().padStart(5)} reached only from ./super-editor`); +console.log(` ${both.toString().padStart(5)} reached from both`); + +// JSON attribution report. Lives under tmp/ (gitignored). PR 3 reads +// this to drive strict-scope selection without re-running the walker. +const tmpDir = resolve(repoRoot, 'tmp'); +try { + require('node:fs').mkdirSync(tmpDir, { recursive: true }); + const reportPath = join(tmpDir, 'deep-type-audit-attribution.json'); + const report = { + generatedAt: new Date().toISOString(), + totals: { + distinct: distinctFindings.size, + byTier: tierCounts, + byEntry: entryCounts, + byRootBucket: rootBucketCounts, + curatedFacadeVsRaw: { curatedOnly, rawOnly, both }, + }, + findings: tieredFindings.map((f) => ({ + kind: f.kind, + file: f.file, + line: f.line, + symbolPath: f.symbolPath, + snippet: f.snippet, + tier: f.tier, + reachedFrom: [...f.reachedFrom].sort(), + rootBuckets: [...f.rootBuckets].sort(), + })), + }; + writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); + console.log(``); + console.log(`[audit] Wrote attribution report: ${relative(repoRoot, reportPath)}`); +} catch (err) { + console.warn(`[audit] WARN: could not write attribution report: ${err.message}`); +} + const haveAllowlist = existsSync(allowlistPath); if (haveAllowlist) { console.log(``); @@ -558,17 +736,91 @@ if (haveAllowlist) { } } else { console.log(``); - console.log(`[audit] No allowlist present (deep-type-audit.allowlist.json).`); - console.log(`[audit] This is expected pre-SD-2966: the audit is inventory-only until the public facade is defined.`); - console.log(`[audit] Once SD-2966 lands, run \`node deep-type-audit.mjs --write\` to seed an allowlist scoped to the facade.`); + console.log(`[audit] No broad allowlist present (deep-type-audit.allowlist.json).`); + console.log(`[audit] The supported-root strict gate runs separately (--strict-supported-root); see the [supported-root] section below.`); } -if (!doStrict) { +// SD-3213e: supported-root strict gate report. Always print when there +// is a supported-root allowlist, regardless of mode, so reviewers see +// drain progress and top offenders even in report-only runs. +const haveSupportedRootAllowlist = existsSync(supportedRootAllowlistPath); +if (haveSupportedRootAllowlist) { + console.log(``); + console.log(`[audit] [supported-root] Allowlist (current debt): ${supportedRootAllowlist.entries.length} entries`); + console.log(`[audit] [supported-root] Current findings: ${supportedRootFindings.size}`); + console.log(`[audit] [supported-root] New (not in allowlist): ${newSupportedRoot.length}`); + console.log(`[audit] [supported-root] Stale (in allowlist, drained): ${staleSupportedRootKeys.length}`); + + // Top offenders: which files contribute the most to remaining debt. Drain + // PRs should start from here. Counts come from the CURRENT findings set + // (not the allowlist) so newly-introduced files surface too. + const offenderByFile = {}; + const offenderBySymbol = {}; + for (const f of supportedRootFindings.values()) { + offenderByFile[f.file] = (offenderByFile[f.file] ?? 0) + 1; + const top = (f.symbolPath.match(/^([^.([<=]+)/) ?? [])[1] ?? '?'; + offenderBySymbol[top] = (offenderBySymbol[top] ?? 0) + 1; + } + console.log(``); + console.log(`[audit] [supported-root] Top offender files (drain targets):`); + for (const [k, v] of Object.entries(offenderByFile).sort((a, b) => b[1] - a[1]).slice(0, 5)) { + console.log(` ${v.toString().padStart(5)} ${k}`); + } console.log(``); - console.log(`[audit] PASS (report-only mode; pass --strict to gate CI on findings)`); + console.log(`[audit] [supported-root] Top offender root symbols:`); + for (const [k, v] of Object.entries(offenderBySymbol).sort((a, b) => b[1] - a[1]).slice(0, 5)) { + console.log(` ${v.toString().padStart(5)} ${k}`); + } + + if (newSupportedRoot.length > 0) { + console.log(``); + console.log(`[audit] [supported-root] NEW FINDINGS (regression):`); + for (const f of newSupportedRoot.slice(0, 50)) { + console.log(` + ${f.kind} ${f.symbolPath}`); + console.log(` ${f.file}:${f.line}`); + console.log(` ${f.snippet}`); + } + if (newSupportedRoot.length > 50) console.log(` ... and ${newSupportedRoot.length - 50} more`); + } + if (staleSupportedRootKeys.length > 0) { + console.log(``); + console.log(`[audit] [supported-root] STALE (drain landed; allowlist must shrink):`); + for (const k of staleSupportedRootKeys.slice(0, 50)) { + const e = supportedRootAllowlistByKey.get(k); + console.log(` - ${e.kind} ${e.symbolPath} (${e.file}:${e.line})`); + } + if (staleSupportedRootKeys.length > 50) console.log(` ... and ${staleSupportedRootKeys.length - 50} more`); + } +} + +if (!doStrict && !doStrictSupportedRoot) { + console.log(``); + console.log(`[audit] PASS (report-only mode; pass --strict or --strict-supported-root to gate CI on findings)`); process.exit(0); } +if (doStrictSupportedRoot) { + if (!haveSupportedRootAllowlist && supportedRootFindings.size > 0) { + console.log(``); + console.log(`[audit] FAIL (--strict-supported-root): no supported-root allowlist exists yet but findings are present.`); + console.log(`[audit] - To seed the allowlist, run: node deep-type-audit.mjs --pack --write --strict-supported-root`); + process.exit(1); + } + if (haveSupportedRootAllowlist && (newSupportedRoot.length > 0 || staleSupportedRootKeys.length > 0)) { + console.log(``); + console.log(`[audit] FAIL (--strict-supported-root)`); + console.log(`[audit] - The allowlist is current known debt, not accepted API. New entries are regressions.`); + console.log(`[audit] - Stale entries mean a drain landed; the allowlist must shrink (run --write to regenerate).`); + console.log(`[audit] - To accept an intentional new finding (rare), run: node deep-type-audit.mjs --pack --write --strict-supported-root`); + process.exit(1); + } + if (!doStrict) { + console.log(``); + console.log(`[audit] PASS (--strict-supported-root)`); + process.exit(0); + } +} + if (haveAllowlist && (newFindings.length > 0 || staleAllowlistKeys.length > 0)) { console.log(``); console.log(`[audit] FAIL (--strict)`); diff --git a/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json b/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json new file mode 100644 index 0000000000..b5176cca24 --- /dev/null +++ b/tests/consumer-typecheck/deep-type-audit.supported-root-allowlist.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "scope": "supported-root", + "generatedAt": "2026-05-21T22:55:59.484Z", + "entries": [] +} diff --git a/tests/consumer-typecheck/package.json b/tests/consumer-typecheck/package.json index e13f183ebc..f6afd9480e 100644 --- a/tests/consumer-typecheck/package.json +++ b/tests/consumer-typecheck/package.json @@ -4,9 +4,7 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "typecheck:matrix": "node typecheck-matrix.mjs", - "check:types": "node check-public-types.mjs", - "check:types:write": "node check-public-types.mjs --write" + "typecheck:matrix": "node typecheck-matrix.mjs" }, "dependencies": { "superdoc": "file:../../packages/superdoc/superdoc.tgz" diff --git a/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs b/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs deleted file mode 100644 index 1eea537d32..0000000000 --- a/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -/** - * SD-3176: no-growth gate for `@superdoc/super-editor` package-level exports map. - * - * Snapshots the keys of `packages/super-editor/package.json#exports`. New - * subpath entries (e.g. a fresh `./foo`) fail CI. Removing entries also fails - * the diff so the change gets explicit reviewer attention. - * - * Companion to `snapshot-superdoc-legacy-exports.mjs`, which catches growth - * of resolved named exports through `superdoc/super-editor` and the three - * other legacy subpaths. - * - * Usage: - * node snapshot-super-editor-package-exports.mjs --check - * node snapshot-super-editor-package-exports.mjs --write - * - * `--write` regenerates the snapshot. Only run it when the change is - * intentional and tied to SD-3175 (path-as-contract facade umbrella). - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '..', '..'); -const PKG = resolve(REPO_ROOT, 'packages', 'super-editor', 'package.json'); -const SNAPSHOT = resolve(HERE, 'snapshots', 'super-editor-package-exports.txt'); - -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null; -if (!mode) { - console.error('Usage: snapshot-super-editor-package-exports.mjs --write | --check'); - process.exit(2); -} - -const pkg = JSON.parse(readFileSync(PKG, 'utf8')); -if (!pkg.exports || typeof pkg.exports !== 'object') { - console.error(`[SD-3176] ${PKG} has no exports map.`); - process.exit(1); -} - -const current = Object.keys(pkg.exports).sort().join('\n') + '\n'; - -if (mode === 'write') { - writeFileSync(SNAPSHOT, current, 'utf8'); - console.log(`[SD-3176] Wrote ${SNAPSHOT}`); - process.exit(0); -} - -let baseline; -try { - baseline = readFileSync(SNAPSHOT, 'utf8'); -} catch (err) { - console.error(`[SD-3176] Snapshot not found: ${SNAPSHOT}`); - console.error('Run with --write to seed the baseline.'); - process.exit(1); -} - -if (baseline === current) { - console.log('[SD-3176] super-editor package exports map: no growth.'); - process.exit(0); -} - -const baseSet = new Set(baseline.split('\n').filter(Boolean)); -const curSet = new Set(current.split('\n').filter(Boolean)); -const added = [...curSet].filter((k) => !baseSet.has(k)); -const removed = [...baseSet].filter((k) => !curSet.has(k)); - -console.error('[SD-3176] @superdoc/super-editor package.json#exports drifted:'); -if (added.length) console.error(' added: ' + added.join(', ')); -if (removed.length) console.error(' removed: ' + removed.join(', ')); -console.error(''); -console.error('Per SD-3175 (path-as-contract facade), @superdoc/super-editor is legacy compatibility surface'); -console.error('and must not grow. If this change is intentional (e.g. an approved compat shim), regenerate:'); -console.error(' node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --write'); -console.error('and link the PR to SD-3175 or a child ticket for reviewer sign-off.'); -process.exit(1); diff --git a/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs b/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs deleted file mode 100644 index 8b786b3ae4..0000000000 --- a/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env node -/** - * SD-3176: no-growth gate for legacy `superdoc/*` subpaths. - * - * Snapshots the resolved named exports visible through these subpaths against - * the packed-and-installed tarball: - * - superdoc/super-editor (the dangerous one; `export *` from @superdoc/super-editor) - * - superdoc/converter - * - superdoc/docx-zipper - * - superdoc/file-zipper - * - superdoc/headless-toolbar (SD-3179 reclassified from public to legacy) - * - superdoc/headless-toolbar/react - * - superdoc/headless-toolbar/vue - * - * The authoritative list is the `SUBPATHS` constant below. - * - * Source parsing is insufficient because `superdoc/src/super-editor.js` is - * `export * from '@superdoc/super-editor'`. The contract that ships is what - * a consumer sees through the published declarations. The TypeScript compiler - * resolves the re-export chain for us. - * - * Requires the fixture to be packed-and-installed first. CI runs this after - * `typecheck-matrix.mjs`, which already packs and installs the tarball. - * - * Usage: - * node snapshot-superdoc-legacy-exports.mjs --check - * node snapshot-superdoc-legacy-exports.mjs --write - * - * `--write` regenerates the snapshots. Only run it when the change is - * intentional and tied to SD-3175 (path-as-contract facade umbrella). - */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve, join } from 'node:path'; -import { createRequire } from 'node:module'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SNAPSHOT_DIR = resolve(HERE, 'snapshots'); -const FIXTURE_SUPERDOC = resolve(HERE, 'node_modules', 'superdoc'); - -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null; -if (!mode) { - console.error('Usage: snapshot-superdoc-legacy-exports.mjs --write | --check'); - process.exit(2); -} - -if (!existsSync(FIXTURE_SUPERDOC)) { - console.error('[SD-3176] superdoc is not installed in the fixture.'); - console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (it packs and installs the tarball),'); - console.error('or `npm install ../../packages/superdoc/superdoc.tgz --no-save` from tests/consumer-typecheck.'); - process.exit(1); -} - -// Use the typescript installed in the fixture so the version matches what -// consumer-side tests already use. -const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); -let ts; -try { - ts = req('typescript'); -} catch { - const fixtureReq = createRequire(join(HERE, 'package.json')); - ts = fixtureReq('typescript'); -} - -const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); - -const SUBPATHS = [ - './super-editor', - './converter', - './docx-zipper', - './file-zipper', - // SD-3179 reclassified the headless-toolbar subpaths from public to - // legacy compatibility surface. See package-boundaries.md Decision 4. - './headless-toolbar', - './headless-toolbar/react', - './headless-toolbar/vue', -]; - -function resolveTypesEntries(exportsValue) { - // Returns { import: string|null, require: string|null }. Either can be set. - // Snapshot is keyed on the `import` branch; `require` is a parity check. - if (typeof exportsValue === 'string') return { import: exportsValue, require: null }; - if (exportsValue && typeof exportsValue === 'object') { - if (typeof exportsValue.types === 'string') { - return { import: exportsValue.types, require: null }; - } - if (exportsValue.types && typeof exportsValue.types === 'object') { - return { - import: exportsValue.types.import ?? exportsValue.types.default ?? null, - require: exportsValue.types.require ?? null, - }; - } - } - return { import: null, require: null }; -} - -function snapshotName(subpath) { - return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt'; -} - -function formatDiagnostic(diagnostic) { - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - if (!diagnostic.file || diagnostic.start == null) return message; - const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`; -} - -function listExportedNames(subpath, entryFile) { - const program = ts.createProgram({ - rootNames: [entryFile], - options: { - moduleResolution: ts.ModuleResolutionKind.Bundler, - module: ts.ModuleKind.ESNext, - target: ts.ScriptTarget.ESNext, - noEmit: true, - skipLibCheck: false, - allowJs: false, - declaration: false, - }, - }); - const diagnostics = [ - ...program.getSyntacticDiagnostics(), - ...program.getSemanticDiagnostics(), - ...program.getDeclarationDiagnostics(), - ]; - if (diagnostics.length > 0) { - const details = diagnostics.slice(0, 10).map((diagnostic) => ` - ${formatDiagnostic(diagnostic)}`).join('\n'); - const suffix = diagnostics.length > 10 ? `\n ... ${diagnostics.length - 10} more diagnostics` : ''; - throw new Error(`${subpath} declaration has TypeScript diagnostics:\n${details}${suffix}`); - } - const checker = program.getTypeChecker(); - const source = program.getSourceFile(entryFile); - if (!source) throw new Error('Cannot load source: ' + entryFile); - const symbol = checker.getSymbolAtLocation(source) ?? source.symbol; - if (!symbol) return []; - const exports = checker.getExportsOfModule(symbol); - return [...new Set(exports.map((e) => e.getName()))].sort(); -} - -let failed = false; - -for (const subpath of SUBPATHS) { - const entries = resolveTypesEntries(superdocPkg.exports?.[subpath]); - if (!entries.import) { - console.error(`[SD-3176] No ESM types entry for ${subpath} in installed superdoc.`); - failed = true; - continue; - } - const importFile = resolve(FIXTURE_SUPERDOC, entries.import); - if (!existsSync(importFile)) { - console.error(`[SD-3176] Types file missing for ${subpath}: ${importFile}`); - failed = true; - continue; - } - - let names; - try { - names = listExportedNames(subpath, importFile); - } catch (err) { - console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`); - failed = true; - continue; - } - - // CJS parity check: when the entry advertises both `types.import` and - // `types.require`, both declaration files must enumerate the same names. - // `ensure-types.cjs` generates the .d.cts from the .d.ts today, so this - // is currently a no-op; it guards against a silent regression in the - // generator producing a divergent CJS surface. - if (entries.require) { - const requireFile = resolve(FIXTURE_SUPERDOC, entries.require); - if (!existsSync(requireFile)) { - console.error(`[SD-3176] CJS types file missing for ${subpath}: ${requireFile}`); - failed = true; - continue; - } - let cjsNames; - try { - cjsNames = listExportedNames(subpath, requireFile); - } catch (err) { - console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`); - failed = true; - continue; - } - const importSet = new Set(names); - const requireSet = new Set(cjsNames); - const onlyImport = [...importSet].filter((n) => !requireSet.has(n)); - const onlyRequire = [...requireSet].filter((n) => !importSet.has(n)); - if (onlyImport.length || onlyRequire.length) { - console.error(`[SD-3176] ${subpath}: ESM/CJS declaration export sets differ.`); - if (onlyImport.length) console.error(' import-only: ' + onlyImport.join(', ')); - if (onlyRequire.length) console.error(' require-only: ' + onlyRequire.join(', ')); - console.error(' Fix the CJS generator (packages/superdoc/scripts/ensure-types.cjs) so the two stay in sync.'); - failed = true; - continue; - } - } - - const current = names.join('\n') + '\n'; - const snapshotPath = join(SNAPSHOT_DIR, snapshotName(subpath)); - - if (mode === 'write') { - writeFileSync(snapshotPath, current, 'utf8'); - console.log(`[SD-3176] Wrote ${snapshotPath} (${names.length} names)`); - continue; - } - - let baseline; - try { - baseline = readFileSync(snapshotPath, 'utf8'); - } catch { - console.error(`[SD-3176] Snapshot missing for ${subpath}: ${snapshotPath}`); - console.error(' Run with --write to seed the baseline.'); - failed = true; - continue; - } - - if (baseline === current) { - console.log(`[SD-3176] ${subpath}: no growth (${names.length} names).`); - continue; - } - - const baseSet = new Set(baseline.split('\n').filter(Boolean)); - const curSet = new Set(current.split('\n').filter(Boolean)); - const added = [...curSet].filter((k) => !baseSet.has(k)); - const removed = [...baseSet].filter((k) => !curSet.has(k)); - - console.error(`[SD-3176] superdoc${subpath.slice(1)} exports drifted:`); - if (added.length) console.error(' added: ' + added.join(', ')); - if (removed.length) console.error(' removed: ' + removed.join(', ')); - failed = true; -} - -if (failed && mode === 'check') { - console.error(''); - console.error('Per SD-3175 (path-as-contract facade), these legacy subpaths are no-growth.'); - console.error('If a change is intentional, regenerate the affected snapshot and link the PR'); - console.error('to SD-3175 or a child ticket for reviewer sign-off:'); - console.error(' node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write'); - process.exit(1); -} - -process.exit(0); diff --git a/tests/consumer-typecheck/snapshot.mjs b/tests/consumer-typecheck/snapshot.mjs new file mode 100644 index 0000000000..5f38eec834 --- /dev/null +++ b/tests/consumer-typecheck/snapshot.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * SD-3213b unified snapshot CLI. + * + * One entry point that routes to family modules under ./snapshot/. Each + * family module exports `FAMILY`, `DESCRIPTION`, and `run({ mode })` + * returning `{ code: number }`. + * + * Usage: + * node tests/consumer-typecheck/snapshot.mjs --all --check + * node tests/consumer-typecheck/snapshot.mjs --all --write + * node tests/consumer-typecheck/snapshot.mjs --family root --check + * node tests/consumer-typecheck/snapshot.mjs --family legacy --check + * node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --check + * + * --check (default) compares against committed snapshots and exits non-zero + * on drift. --write regenerates snapshots in place. + * + * CI workflows call `snapshot.mjs --all --check`. The packed-tarball fixture + * must be installed first (the legacy and root families need it); the + * typecheck-matrix step in CI handles that. + */ +import * as superEditorPackage from './snapshot/super-editor-package-exports.mjs'; +import * as legacy from './snapshot/legacy-exports.mjs'; +import * as root from './snapshot/root-exports.mjs'; + +const FAMILIES = [superEditorPackage, legacy, root]; +const FAMILY_BY_NAME = new Map(FAMILIES.map((m) => [m.FAMILY, m])); + +function printUsage() { + console.error('Usage:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --all --check'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --all --write'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family --check'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family --write'); + console.error(''); + console.error('Families:'); + for (const m of FAMILIES) { + console.error(` ${m.FAMILY.padEnd(24)} ${m.DESCRIPTION}`); + } +} + +function parseArgs(argv) { + const args = { all: false, family: null, mode: 'check' }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--all') args.all = true; + else if (a === '--family') args.family = argv[++i]; + else if (a === '--check') args.mode = 'check'; + else if (a === '--write') args.mode = 'write'; + else if (a === '-h' || a === '--help') args.help = true; + else { + console.error(`Unknown argument: ${a}`); + args.invalid = true; + } + } + return args; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help || args.invalid) { + printUsage(); + process.exit(args.invalid ? 2 : 0); + } + + if (args.all && args.family) { + console.error('--all and --family are mutually exclusive.'); + printUsage(); + process.exit(2); + } + + if (!args.all && !args.family) { + console.error('Specify either --all or --family .'); + printUsage(); + process.exit(2); + } + + const targets = args.all + ? FAMILIES + : [FAMILY_BY_NAME.get(args.family)].filter(Boolean); + + if (args.family && targets.length === 0) { + console.error(`Unknown family: ${args.family}`); + printUsage(); + process.exit(2); + } + + let exitCode = 0; + for (const mod of targets) { + console.log(`\n=== [${mod.FAMILY}] ${mod.DESCRIPTION} ===`); + const { code } = mod.run({ mode: args.mode }); + if (code !== 0) exitCode = code; + } + process.exit(exitCode); +} + +main(); diff --git a/tests/consumer-typecheck/snapshot/legacy-exports.mjs b/tests/consumer-typecheck/snapshot/legacy-exports.mjs new file mode 100644 index 0000000000..6bbd8a213e --- /dev/null +++ b/tests/consumer-typecheck/snapshot/legacy-exports.mjs @@ -0,0 +1,235 @@ +/** + * SD-3176 family: no-growth gate for legacy `superdoc/*` subpaths. + * + * Snapshots the resolved named exports visible through each legacy subpath + * against the packed-and-installed tarball: + * - superdoc/super-editor (the dangerous one; `export *` from @superdoc/super-editor) + * - superdoc/converter + * - superdoc/docx-zipper + * - superdoc/file-zipper + * - superdoc/headless-toolbar (SD-3179 reclassified from public to legacy) + * - superdoc/headless-toolbar/react + * - superdoc/headless-toolbar/vue + * + * Source parsing is insufficient because `superdoc/src/super-editor.js` is + * `export * from '@superdoc/super-editor'`. The contract that ships is what + * a consumer sees through the published declarations. The TypeScript + * compiler resolves the re-export chain for us. + * + * Requires the fixture to be packed-and-installed first. CI runs this + * after `typecheck-matrix.mjs`, which already packs and installs the + * tarball. + * + * Extracted from the standalone `snapshot-superdoc-legacy-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry + * point is now `tests/consumer-typecheck/snapshot.mjs`. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, join } from 'node:path'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CONSUMER_TYPECHECK = resolve(HERE, '..'); +const SNAPSHOT_DIR = resolve(CONSUMER_TYPECHECK, 'snapshots'); +const FIXTURE_SUPERDOC = resolve(CONSUMER_TYPECHECK, 'node_modules', 'superdoc'); + +export const FAMILY = 'legacy'; +export const DESCRIPTION = 'superdoc/* legacy subpath resolved exports (SD-3176)'; + +const SUBPATHS = [ + './super-editor', + './converter', + './docx-zipper', + './file-zipper', + // SD-3179 reclassified the headless-toolbar subpaths from public to + // legacy compatibility surface. See package-boundaries.md Decision 4. + './headless-toolbar', + './headless-toolbar/react', + './headless-toolbar/vue', +]; + +function resolveTypesEntries(exportsValue) { + // Returns { import: string|null, require: string|null }. Either can be set. + // Snapshot is keyed on the `import` branch; `require` is a parity check. + if (typeof exportsValue === 'string') return { import: exportsValue, require: null }; + if (exportsValue && typeof exportsValue === 'object') { + if (typeof exportsValue.types === 'string') { + return { import: exportsValue.types, require: null }; + } + if (exportsValue.types && typeof exportsValue.types === 'object') { + return { + import: exportsValue.types.import ?? exportsValue.types.default ?? null, + require: exportsValue.types.require ?? null, + }; + } + } + return { import: null, require: null }; +} + +function snapshotName(subpath) { + return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt'; +} + +function loadTypescript() { + const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); + try { + return req('typescript'); + } catch { + const fixtureReq = createRequire(join(CONSUMER_TYPECHECK, 'package.json')); + return fixtureReq('typescript'); + } +} + +function formatDiagnostic(ts, diagnostic) { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (!diagnostic.file || diagnostic.start == null) return message; + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`; +} + +function listExportedNames(ts, subpath, entryFile) { + const program = ts.createProgram({ + rootNames: [entryFile], + options: { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + noEmit: true, + skipLibCheck: false, + allowJs: false, + declaration: false, + }, + }); + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getDeclarationDiagnostics(), + ]; + if (diagnostics.length > 0) { + const details = diagnostics.slice(0, 10).map((diagnostic) => ` - ${formatDiagnostic(ts, diagnostic)}`).join('\n'); + const suffix = diagnostics.length > 10 ? `\n ... ${diagnostics.length - 10} more diagnostics` : ''; + throw new Error(`${subpath} declaration has TypeScript diagnostics:\n${details}${suffix}`); + } + const checker = program.getTypeChecker(); + const source = program.getSourceFile(entryFile); + if (!source) throw new Error('Cannot load source: ' + entryFile); + const symbol = checker.getSymbolAtLocation(source) ?? source.symbol; + if (!symbol) return []; + const exports = checker.getExportsOfModule(symbol); + return [...new Set(exports.map((e) => e.getName()))].sort(); +} + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + if (!existsSync(FIXTURE_SUPERDOC)) { + console.error('[SD-3176] superdoc is not installed in the fixture.'); + console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (it packs and installs the tarball),'); + console.error('or `npm install ../../packages/superdoc/superdoc.tgz --no-save` from tests/consumer-typecheck.'); + return { code: 1 }; + } + + const ts = loadTypescript(); + const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); + let failed = false; + + for (const subpath of SUBPATHS) { + const entries = resolveTypesEntries(superdocPkg.exports?.[subpath]); + if (!entries.import) { + console.error(`[SD-3176] No ESM types entry for ${subpath} in installed superdoc.`); + failed = true; + continue; + } + const importFile = resolve(FIXTURE_SUPERDOC, entries.import); + if (!existsSync(importFile)) { + console.error(`[SD-3176] Types file missing for ${subpath}: ${importFile}`); + failed = true; + continue; + } + + let names; + try { + names = listExportedNames(ts, subpath, importFile); + } catch (err) { + console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`); + failed = true; + continue; + } + + if (entries.require) { + const requireFile = resolve(FIXTURE_SUPERDOC, entries.require); + if (!existsSync(requireFile)) { + console.error(`[SD-3176] CJS types file missing for ${subpath}: ${requireFile}`); + failed = true; + continue; + } + let cjsNames; + try { + cjsNames = listExportedNames(ts, subpath, requireFile); + } catch (err) { + console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`); + failed = true; + continue; + } + const importSet = new Set(names); + const requireSet = new Set(cjsNames); + const onlyImport = [...importSet].filter((n) => !requireSet.has(n)); + const onlyRequire = [...requireSet].filter((n) => !importSet.has(n)); + if (onlyImport.length || onlyRequire.length) { + console.error(`[SD-3176] ${subpath}: ESM/CJS declaration export sets differ.`); + if (onlyImport.length) console.error(' import-only: ' + onlyImport.join(', ')); + if (onlyRequire.length) console.error(' require-only: ' + onlyRequire.join(', ')); + console.error(' Fix the CJS generator (packages/superdoc/scripts/ensure-types.cjs) so the two stay in sync.'); + failed = true; + continue; + } + } + + const current = names.join('\n') + '\n'; + const snapshotPath = join(SNAPSHOT_DIR, snapshotName(subpath)); + + if (mode === 'write') { + writeFileSync(snapshotPath, current, 'utf8'); + console.log(`[SD-3176] Wrote ${snapshotPath} (${names.length} names)`); + continue; + } + + let baseline; + try { + baseline = readFileSync(snapshotPath, 'utf8'); + } catch { + console.error(`[SD-3176] Snapshot missing for ${subpath}: ${snapshotPath}`); + console.error(' Run with --write to seed the baseline.'); + failed = true; + continue; + } + + if (baseline === current) { + console.log(`[SD-3176] ${subpath}: no growth (${names.length} names).`); + continue; + } + + const baseSet = new Set(baseline.split('\n').filter(Boolean)); + const curSet = new Set(current.split('\n').filter(Boolean)); + const added = [...curSet].filter((k) => !baseSet.has(k)); + const removed = [...baseSet].filter((k) => !curSet.has(k)); + + console.error(`[SD-3176] superdoc${subpath.slice(1)} exports drifted:`); + if (added.length) console.error(' added: ' + added.join(', ')); + if (removed.length) console.error(' removed: ' + removed.join(', ')); + failed = true; + } + + if (failed && mode === 'check') { + console.error(''); + console.error('Per SD-3175 (path-as-contract facade), these legacy subpaths are no-growth.'); + console.error('If a change is intentional, regenerate the affected snapshot and link the PR'); + console.error('to SD-3175 or a child ticket for reviewer sign-off:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family legacy --write'); + return { code: 1 }; + } + return { code: failed ? 1 : 0 }; +} diff --git a/tests/consumer-typecheck/snapshot/root-exports.mjs b/tests/consumer-typecheck/snapshot/root-exports.mjs new file mode 100644 index 0000000000..2f4a2ae987 --- /dev/null +++ b/tests/consumer-typecheck/snapshot/root-exports.mjs @@ -0,0 +1,390 @@ +/** + * SD-3212 family: no-growth gate + evidence inventory for the `superdoc` + * ROOT entry. + * + * The root entry resolves through four package.json#exports fields, + * which can diverge: + * - types.import โ†’ dist/superdoc/src/public/index.d.ts + * - types.require โ†’ dist/superdoc/src/public/index.d.cts + * - import โ†’ dist/superdoc.es.js + * - require โ†’ dist/superdoc.cjs + * + * This snapshot locks the exported-name set of each of the four sources + * against drift. Cross-source mismatches are surfaced as evidence rows + * in the companion `.md` report but are NOT a drift blocker on their own; + * the four name sets each have their own committed baseline. + * + * Requires the fixture to be packed-and-installed first. The CLI runs + * this after `typecheck-matrix.mjs`, which packs and installs. + * + * Extracted from the standalone `snapshot-superdoc-root-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry + * point is now `tests/consumer-typecheck/snapshot.mjs`. + */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, join, relative } from 'node:path'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CONSUMER_TYPECHECK = resolve(HERE, '..'); +const REPO_ROOT = resolve(CONSUMER_TYPECHECK, '..', '..'); +const SNAPSHOT_DIR = resolve(CONSUMER_TYPECHECK, 'snapshots'); +const FIXTURE_SUPERDOC = resolve(CONSUMER_TYPECHECK, 'node_modules', 'superdoc'); +const SNAPSHOT_JSON = join(SNAPSHOT_DIR, 'superdoc-root-exports.json'); +const SNAPSHOT_MD = join(SNAPSHOT_DIR, 'superdoc-root-exports.md'); + +export const FAMILY = 'root'; +export const DESCRIPTION = '4-source root entry inventory + evidence report (SD-3212 A0)'; + +function loadTypescript() { + const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); + try { return req('typescript'); } catch { + return createRequire(join(CONSUMER_TYPECHECK, 'package.json'))('typescript'); + } +} + +function resolveRootSources(rootExport) { + const out = { 'types.import': null, 'types.require': null, import: null, require: null }; + if (rootExport.types && typeof rootExport.types === 'object') { + out['types.import'] = rootExport.types.import ?? rootExport.types.default ?? null; + out['types.require'] = rootExport.types.require ?? null; + } else if (typeof rootExport.types === 'string') { + out['types.import'] = rootExport.types; + } + out.import = rootExport.import ?? null; + out.require = rootExport.require ?? null; + return out; +} + +function enumerateDtsExports(ts, entryFile) { + const program = ts.createProgram({ + rootNames: [entryFile], + options: { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + noEmit: true, + skipLibCheck: true, + allowJs: false, + declaration: false, + }, + }); + const checker = program.getTypeChecker(); + const source = program.getSourceFile(entryFile); + if (!source) throw new Error('Cannot load: ' + entryFile); + const symbol = checker.getSymbolAtLocation(source) ?? source.symbol; + if (!symbol) return []; + return [...new Set(checker.getExportsOfModule(symbol).map((e) => e.getName()))].sort(); +} + +function enumerateEsmBundleExports(entryFile) { + const src = readFileSync(entryFile, 'utf8'); + const names = new Set(); + const blockRe = /export\s*\{([\s\S]*?)\}\s*;?/g; + let m; + while ((m = blockRe.exec(src))) { + const body = m[1]; + for (const rawSpec of body.split(',')) { + const spec = rawSpec.trim().replace(/\s+/g, ' ').replace(/^type\s+/, ''); + if (!spec) continue; + const asMatch = spec.match(/^\S+\s+as\s+(\S+)$/); + const name = asMatch ? asMatch[1] : spec; + if (/^[$A-Z_a-z][$\w]*$/.test(name)) names.add(name); + } + } + if (/^[ \t]*export\s+default\s+/m.test(src)) names.add('default'); + return [...names].sort(); +} + +function enumerateCjsBundleExports(entryFile) { + const src = readFileSync(entryFile, 'utf8'); + const names = new Set(); + const moduleExportsRe = /module\.exports\s*=\s*\{([\s\S]*?)\}\s*;/g; + let m; + while ((m = moduleExportsRe.exec(src))) { + const body = m[1]; + const keyRe = /(?:^|,)\s*(?:get\s+)?["']?([$A-Z_a-z][$\w]*)["']?\s*(?::|[,}\n])/g; + let km; + while ((km = keyRe.exec(body))) names.add(km[1]); + } + const defPropRe = /Object\.defineProperty\((?:module\.)?exports\s*,\s*["']([$A-Z_a-z][$\w]*)["']/g; + while ((m = defPropRe.exec(src))) names.add(m[1]); + const expAssignRe = /(?:^|;|\n)\s*exports\.([$A-Z_a-z][$\w]*)\s*=/g; + while ((m = expAssignRe.exec(src))) names.add(m[1]); + return [...names].sort(); +} + +function walkFiles(dir, exts, out = [], skip = new Set(['node_modules', 'dist', '.git', '.tmp', 'tmp'])) { + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (skip.has(entry.name)) continue; + const p = join(dir, entry.name); + if (entry.isDirectory()) walkFiles(p, exts, out, skip); + else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p); + } + return out; +} + +function countFixtureImports(allNames) { + const fixtureDir = resolve(CONSUMER_TYPECHECK, 'src'); + const files = walkFiles(fixtureDir, ['.ts', '.tsx', '.cts', '.mts']); + const counts = new Map(allNames.map((n) => [n, 0])); + const importBlockRe = /import\s+(?:type\s+)?\{([^}]+)\}\s*from\s+['"]superdoc['"]/g; + for (const file of files) { + const src = readFileSync(file, 'utf8'); + let m; + while ((m = importBlockRe.exec(src))) { + const block = m[1].replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const rawSpec of block.split(',')) { + const spec = rawSpec.trim().replace(/^type\s+/, ''); + const name = spec.split(/\s+as\s+/)[0].trim(); + if (counts.has(name)) counts.set(name, counts.get(name) + 1); + } + } + } + return counts; +} + +function readJsdocTypedefs() { + const indexJs = resolve(REPO_ROOT, 'packages/superdoc/src/index.js'); + if (!existsSync(indexJs)) return new Set(); + const src = readFileSync(indexJs, 'utf8'); + const set = new Set(); + const re = /@typedef\s+\{[^}]+\}\s+([$A-Z_a-z][$\w]*)/g; + let m; + while ((m = re.exec(src))) set.add(m[1]); + return set; +} + +function countMentionsIn(rootDir, allNames, exts) { + const counts = new Map(allNames.map((n) => [n, 0])); + if (!existsSync(rootDir)) return counts; + const files = walkFiles(rootDir, exts); + for (let i = 0; i < allNames.length; i += 200) { + const batch = allNames.slice(i, i + 200).filter((n) => /^[$A-Z_a-z][$\w]*$/.test(n)); + if (!batch.length) continue; + const re = new RegExp('\\b(' + batch.join('|') + ')\\b', 'g'); + for (const f of files) { + const src = readFileSync(f, 'utf8'); + let m; + while ((m = re.exec(src))) counts.set(m[1], counts.get(m[1]) + 1); + } + } + return counts; +} + +function inPackageBoundaries(allNames) { + const file = resolve(REPO_ROOT, 'docs/architecture/package-boundaries.md'); + if (!existsSync(file)) return new Set(); + const src = readFileSync(file, 'utf8'); + const set = new Set(); + for (const n of allNames) { + if (new RegExp('\\b' + n + '\\b').test(src)) set.add(n); + } + return set; +} + +function tick(v) { return v ? 'โœ“' : ' '; } +function renderMarkdown(snapshot, allNames, inDts, inDcts, inEsm, inCjs, fixtureCounts, jsdocSet, docCounts, exampleCounts, demoCounts, inBoundaries) { + const lines = []; + lines.push('# superdoc root export inventory (SD-3212 PR A0)'); + lines.push(''); + lines.push(`Generated: ${snapshot.generatedAt}`); + lines.push(`Source: packed and installed \`tests/consumer-typecheck/node_modules/superdoc\``); + lines.push(''); + lines.push('## Counts'); + lines.push(''); + lines.push('| Source | Path | Count |'); + lines.push('|---|---|---|'); + for (const key of ['types.import', 'types.require', 'import', 'require']) { + const s = snapshot.sources[key]; + lines.push(`| ${key} | \`${s.path || '(missing)'}\` | ${s.names.length} |`); + } + lines.push(`| **union** | | **${snapshot.counts.union}** |`); + lines.push(''); + lines.push('## Divergences'); + lines.push(''); + const d = snapshot.divergences; + lines.push(`- types.import only (not in types.require): ${d.typesImportVsRequire.onlyInImport.length}`); + lines.push(`- types.require only (not in types.import): ${d.typesImportVsRequire.onlyInRequire.length}`); + lines.push(`- ESM only (not in CJS): ${d.esmVsCjs.onlyInEsm.length}`); + lines.push(`- CJS only (not in ESM): ${d.esmVsCjs.onlyInCjs.length}`); + lines.push(`- typed but no runtime export (phantom risk): ${d.typesVsRuntime.typedOnly.length}`); + lines.push(`- runtime export but not typed (silent shadow on root): ${d.typesVsRuntime.runtimeOnly.length}`); + lines.push(''); + if (d.typesVsRuntime.runtimeOnly.length > 0) { + lines.push('### Runtime-only names (no type)'); + lines.push(''); + for (const n of d.typesVsRuntime.runtimeOnly) lines.push(`- \`${n}\``); + lines.push(''); + } + if (d.typesVsRuntime.typedOnly.length > 0) { + lines.push('### Type-only names (no runtime)'); + lines.push(''); + for (const n of d.typesVsRuntime.typedOnly) lines.push(`- \`${n}\``); + lines.push(''); + } + lines.push('## Evidence table'); + lines.push(''); + lines.push('| Name | dts | dcts | esm | cjs | fixtures | jsdoc | docs | examples | demos | boundaries |'); + lines.push('|---|---|---|---|---|---|---|---|---|---|---|'); + for (const n of allNames) { + lines.push( + `| \`${n}\` | ${tick(inDts.has(n))} | ${tick(inDcts.has(n))} | ${tick(inEsm.has(n))} | ${tick(inCjs.has(n))} | ` + + `${fixtureCounts.get(n) || 0} | ${tick(jsdocSet.has(n))} | ${docCounts.get(n) || 0} | ${exampleCounts.get(n) || 0} | ` + + `${demoCounts.get(n) || 0} | ${tick(inBoundaries.has(n))} |`, + ); + } + return lines.join('\n') + '\n'; +} + +function compareLocked(actualSnapshot) { + if (!existsSync(SNAPSHOT_JSON)) { + return { ok: false, reason: `Snapshot does not exist at ${relative(REPO_ROOT, SNAPSHOT_JSON)}. Run --write.` }; + } + const committed = JSON.parse(readFileSync(SNAPSHOT_JSON, 'utf8')); + const violations = []; + for (const key of ['types.import', 'types.require', 'import', 'require']) { + const a = (actualSnapshot.sources[key]?.names || []).join(','); + const c = (committed.sources?.[key]?.names || []).join(','); + if (a !== c) { + const aSet = new Set(actualSnapshot.sources[key]?.names || []); + const cSet = new Set(committed.sources?.[key]?.names || []); + const added = [...aSet].filter((n) => !cSet.has(n)).sort(); + const removed = [...cSet].filter((n) => !aSet.has(n)).sort(); + violations.push({ source: key, added, removed }); + } + } + return { ok: violations.length === 0, violations }; +} + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + if (!existsSync(FIXTURE_SUPERDOC)) { + console.error('[SD-3212] superdoc is not installed in the fixture.'); + console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (packs and installs).'); + return { code: 1 }; + } + + const ts = loadTypescript(); + const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); + const rootExport = superdocPkg.exports?.['.']; + if (!rootExport || typeof rootExport !== 'object') { + console.error('[SD-3212] No root export found in installed superdoc package.json#exports'); + return { code: 1 }; + } + + const sources = resolveRootSources(rootExport); + const enumerated = {}; + for (const [key, relPath] of Object.entries(sources)) { + if (!relPath) { + enumerated[key] = { path: null, names: [], error: 'not declared in package.json#exports' }; + continue; + } + const abs = resolve(FIXTURE_SUPERDOC, relPath); + if (!existsSync(abs)) { + enumerated[key] = { path: relPath, names: [], error: 'file missing' }; + continue; + } + try { + if (key === 'types.import' || key === 'types.require') { + enumerated[key] = { path: relPath, names: enumerateDtsExports(ts, abs), error: null }; + } else if (key === 'import') { + enumerated[key] = { path: relPath, names: enumerateEsmBundleExports(abs), error: null }; + } else if (key === 'require') { + enumerated[key] = { path: relPath, names: enumerateCjsBundleExports(abs), error: null }; + } + } catch (err) { + enumerated[key] = { path: relPath, names: [], error: err.message }; + } + } + + const allNames = [...new Set([ + ...enumerated['types.import'].names, + ...enumerated['types.require'].names, + ...enumerated['import'].names, + ...enumerated['require'].names, + ])].sort(); + + const inDts = new Set(enumerated['types.import'].names); + const inDcts = new Set(enumerated['types.require'].names); + const inEsm = new Set(enumerated['import'].names); + const inCjs = new Set(enumerated['require'].names); + + const snapshot = { + generatedAt: new Date().toISOString(), + ticket: 'SD-3212 PR A0', + package: 'superdoc', + rootExport, + sources: { + 'types.import': enumerated['types.import'], + 'types.require': enumerated['types.require'], + import: enumerated['import'], + require: enumerated['require'], + }, + counts: { + 'types.import': enumerated['types.import'].names.length, + 'types.require': enumerated['types.require'].names.length, + import: enumerated['import'].names.length, + require: enumerated['require'].names.length, + union: allNames.length, + }, + divergences: { + typesImportVsRequire: { + onlyInImport: enumerated['types.import'].names.filter((n) => !inDcts.has(n)), + onlyInRequire: enumerated['types.require'].names.filter((n) => !inDts.has(n)), + }, + esmVsCjs: { + onlyInEsm: enumerated['import'].names.filter((n) => !inCjs.has(n)), + onlyInCjs: enumerated['require'].names.filter((n) => !inEsm.has(n)), + }, + typesVsRuntime: { + typedOnly: allNames.filter((n) => (inDts.has(n) || inDcts.has(n)) && !inEsm.has(n) && !inCjs.has(n)), + runtimeOnly: allNames.filter((n) => !inDts.has(n) && !inDcts.has(n) && (inEsm.has(n) || inCjs.has(n))), + }, + }, + }; + + if (mode === 'write') { + const fixtureCounts = countFixtureImports(allNames); + const jsdocSet = readJsdocTypedefs(); + const docCounts = countMentionsIn(resolve(REPO_ROOT, 'apps/docs'), allNames, ['.md', '.mdx', '.ts', '.tsx']); + const exampleCounts = countMentionsIn(resolve(REPO_ROOT, 'examples'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); + const demoCounts = countMentionsIn(resolve(REPO_ROOT, 'demos'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); + const inBoundaries = inPackageBoundaries(allNames); + + writeFileSync(SNAPSHOT_JSON, JSON.stringify(snapshot, null, 2) + '\n'); + writeFileSync(SNAPSHOT_MD, renderMarkdown(snapshot, allNames, inDts, inDcts, inEsm, inCjs, fixtureCounts, jsdocSet, docCounts, exampleCounts, demoCounts, inBoundaries)); + console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_JSON)}`); + console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_MD)}`); + console.log('Counts:'); + for (const key of ['types.import', 'types.require', 'import', 'require']) { + console.log(` ${key}: ${snapshot.sources[key].names.length}`); + } + console.log(` union: ${snapshot.counts.union}`); + return { code: 0 }; + } + + const result = compareLocked(snapshot); + if (result.reason) { + console.error(`[SD-3212] ${result.reason}`); + return { code: 1 }; + } + if (!result.ok) { + console.error('[SD-3212] Root export drift detected:'); + for (const v of result.violations) { + console.error(` source: ${v.source}`); + if (v.added.length) console.error(` + added: ${v.added.join(', ')}`); + if (v.removed.length) console.error(` - removed: ${v.removed.join(', ')}`); + } + console.error(''); + console.error('If this change is intentional, run --write and commit the updated snapshot.'); + return { code: 1 }; + } + console.log('[SD-3212] Root exports match the committed snapshot.'); + return { code: 0 }; +} diff --git a/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs b/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs new file mode 100644 index 0000000000..727eb1a819 --- /dev/null +++ b/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs @@ -0,0 +1,68 @@ +/** + * SD-3176 family: no-growth gate for `@superdoc/super-editor`'s + * package.json#exports keys. + * + * Extracted from the standalone `snapshot-super-editor-package-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry point + * is now `tests/consumer-typecheck/snapshot.mjs`; this file exposes a `run` + * function that the CLI invokes. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..'); +const PKG = resolve(REPO_ROOT, 'packages', 'super-editor', 'package.json'); +const SNAPSHOT = resolve(HERE, '..', 'snapshots', 'super-editor-package-exports.txt'); + +export const FAMILY = 'super-editor-package'; +export const DESCRIPTION = '@superdoc/super-editor package.json#exports keys (SD-3176)'; + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + const pkg = JSON.parse(readFileSync(PKG, 'utf8')); + if (!pkg.exports || typeof pkg.exports !== 'object') { + console.error(`[SD-3176] ${PKG} has no exports map.`); + return { code: 1 }; + } + const current = Object.keys(pkg.exports).sort().join('\n') + '\n'; + + if (mode === 'write') { + writeFileSync(SNAPSHOT, current, 'utf8'); + console.log(`[SD-3176] Wrote ${SNAPSHOT}`); + return { code: 0 }; + } + + let baseline; + try { + baseline = readFileSync(SNAPSHOT, 'utf8'); + } catch (err) { + console.error(`[SD-3176] Snapshot not found: ${SNAPSHOT}`); + console.error('Run with --write to seed the baseline.'); + return { code: 1 }; + } + + if (baseline === current) { + console.log('[SD-3176] super-editor package exports map: no growth.'); + return { code: 0 }; + } + + const baseSet = new Set(baseline.split('\n').filter(Boolean)); + const curSet = new Set(current.split('\n').filter(Boolean)); + const added = [...curSet].filter((k) => !baseSet.has(k)); + const removed = [...baseSet].filter((k) => !curSet.has(k)); + + console.error('[SD-3176] @superdoc/super-editor package.json#exports drifted:'); + if (added.length) console.error(' added: ' + added.join(', ')); + if (removed.length) console.error(' removed: ' + removed.join(', ')); + console.error(''); + console.error('Per SD-3175 (path-as-contract facade), @superdoc/super-editor is legacy compatibility surface'); + console.error('and must not grow. If this change is intentional (e.g. an approved compat shim), regenerate:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --write'); + console.error('and link the PR to SD-3175 or a child ticket for reviewer sign-off.'); + return { code: 1 }; +} diff --git a/tests/consumer-typecheck/snapshots/README.md b/tests/consumer-typecheck/snapshots/README.md index d2a99917c0..f89d360e56 100644 --- a/tests/consumer-typecheck/snapshots/README.md +++ b/tests/consumer-typecheck/snapshots/README.md @@ -14,11 +14,15 @@ These files lock the public TypeScript surface that ships through SuperDoc's leg | `superdoc-headless-toolbar.txt` | Resolved exports through `superdoc/headless-toolbar` | Reclassified as legacy in SD-3179 ahead of the `superdoc/ui` migration. 16-name surface; freeze check. | | `superdoc-headless-toolbar-react.txt` | Resolved exports through `superdoc/headless-toolbar/react` | Framework helper paired with `superdoc/headless-toolbar`. Migration target: `superdoc/ui/react`. | | `superdoc-headless-toolbar-vue.txt` | Resolved exports through `superdoc/headless-toolbar/vue` | Framework helper paired with `superdoc/headless-toolbar`. Migration target: tracked separately. | +| `superdoc-root-exports.json` | 4-source root inventory (`types.import` / `types.require` / `import` / `require`) | SD-3212 PR A0. Drift gate on each source's name set independently. Cross-source mismatches (typed-only, runtime-only, ESM vs CJS) reported in the companion `.md` as evidence for the SD-3212 classification pass. | +| `superdoc-root-exports.md` | Companion evidence report for the above | Regenerated on `--write`; not a drift gate. Includes per-name evidence: presence in each source, fixture import count, JSDoc typedef membership, docs/examples/demos mentions, `package-boundaries.md` reference. | +| `superdoc-root-classification.json` | SD-3212 PR A1 classification | Each of the 200 root names assigned a bucket (`supported-root` / `legacy-root` / `move-to-subpath` / `internal-candidate`) with rationale and confidence. Decision document for PR B (re-curation) and PR C (root types flip). Applies dependency-closure rule: any type required by a supported-root or legacy-root exported class/method is at least `legacy-root`. Not a drift gate. | +| `superdoc-root-classification.md` | Companion human-review surface for the classification | Grouped by bucket with per-name rationale. | -Snapshot scripts: +Snapshot scripts (SD-3213b consolidated all three into one CLI): -- `tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs` -- `tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs` +- `tests/consumer-typecheck/snapshot.mjs` -- unified entry point. Dispatches to the family modules under `tests/consumer-typecheck/snapshot/` (`super-editor-package-exports.mjs`, `legacy-exports.mjs`, `root-exports.mjs`). +- CI calls `node tests/consumer-typecheck/snapshot.mjs --all --check`. ## What to do when CI fails @@ -32,13 +36,16 @@ The failure message tells you which snapshot drifted, what was added, and what w **When growth is intentional** (rare: an explicitly approved compat shim for a legacy customer, an accepted deprecation alias, or similar): 1. Make sure the PR links to SD-3175 or a child ticket so the architectural reviewer sees the justification. -2. Regenerate the affected snapshot: +2. Regenerate the affected family: ```bash - # Snapshot A (package exports map): - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --write + # Snapshot A (package exports map, source-only): + node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --write - # Snapshot B (resolved exports โ€” requires fixture installed): - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write + # Snapshot B (resolved exports, requires fixture installed): + node tests/consumer-typecheck/snapshot.mjs --family legacy --write + + # Snapshot C (root entry 4-source inventory, requires fixture installed): + node tests/consumer-typecheck/snapshot.mjs --family root --write ``` 3. Commit the updated snapshot together with the change that caused it. Reviewer reads both as one decision. @@ -48,10 +55,10 @@ The failure message tells you which snapshot drifted, what was added, and what w Snapshot A (source-only, no fixture needed): ```bash -node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check +node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --check ``` -Snapshot B requires the packed-and-installed fixture under `tests/consumer-typecheck/node_modules/superdoc/`. The matrix script sets this up: +Snapshots B and C require the packed-and-installed fixture under `tests/consumer-typecheck/node_modules/superdoc/`. The matrix script sets this up: ```bash # Either run the full matrix first (it packs and installs): node tests/consumer-typecheck/typecheck-matrix.mjs @@ -60,13 +67,14 @@ node tests/consumer-typecheck/typecheck-matrix.mjs pnpm --filter superdoc run pack:es # repo root cd tests/consumer-typecheck npm install ../../packages/superdoc/superdoc.tgz --no-save -node snapshot-superdoc-legacy-exports.mjs --check +cd ../.. +node tests/consumer-typecheck/snapshot.mjs --all --check ``` ## What this gate does NOT do -- Does not classify supported public surfaces (root `superdoc`, `superdoc/ui`, etc.). That work lives in `tests/consumer-typecheck/public-facade-policy.json` and SD-2966 / SD-3147. +- Does not classify supported public surfaces (root `superdoc`, `superdoc/ui`, etc.). Root classification lives in `tests/consumer-typecheck/snapshots/superdoc-root-classification.json` (SD-3212 PR A1); subpath facade decisions live in `packages/superdoc/scripts/verify-public-facade-emit.cjs` and `docs/architecture/package-boundaries.md` under SD-3147 / SD-3175. - Does not catch leaks through non-legacy paths. The full path-as-contract facade lands under SD-3175. - Does not lock the *types* of exported symbols, only their names. A breaking change to an existing export's shape passes this gate. -- Does not run against arbitrary subpaths. Only the files listed in the table above are tracked. The authoritative list lives in `SUBPATHS` inside `tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs`. +- Does not run against arbitrary subpaths. Only the files listed in the table above are tracked. The authoritative list lives in `SUBPATHS` inside `tests/consumer-typecheck/snapshot/legacy-exports.mjs`. - Does not enumerate every file reachable through existing wildcard export-map keys in `@superdoc/super-editor` (e.g. `"./*"`, `"./converter/internal/*"`). Snapshot A freezes the export-map key set; Snapshot B freezes the resolved `superdoc/super-editor` named export surface. A new file added under an existing wildcard that a consumer reaches via deep import (`@superdoc/super-editor/something-new`) passes both gates. Wildcard removal or shrinkage belongs to the later compat/major phases of SD-3175. diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json new file mode 100644 index 0000000000..8c7a8a72c8 --- /dev/null +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json @@ -0,0 +1,2218 @@ +{ + "generatedAt": "2026-05-19T11:33:50.546Z", + "summary": { + "total": 200, + "byBucket": { + "legacy-root": 60, + "internal-candidate": 8, + "supported-root": 132 + }, + "byConfidence": { + "high": 98, + "medium": 100, + "low": 2 + } + }, + "rows": [ + { + "name": "AIWriter", + "bucket": "legacy-root", + "rationale": "Internal Vue component used by AI UI. Real runtime export but no documented standalone import.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "AnnotatorHelpers", + "bucket": "internal-candidate", + "rationale": "Implementation helper in packages/super-editor/.../helpers/annotator.js, used internally by Editor.ts. No source-side public usage.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "AwarenessState", + "bucket": "supported-root", + "rationale": "Collaboration/awareness type defined in core/types/index.ts. Customer-facing for collab-provider integrations (e.g., AwarenessState types the documented onAwarenessUpdate callback).", + "confidence": "medium", + "source": "collab", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BinaryData", + "bucket": "supported-root", + "rationale": "Shape of binary content used in documented import/export/open/save paths. Type-reachable through documented APIs.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BlankDOCX", + "bucket": "legacy-root", + "rationale": "Runtime-exported empty-DOCX builder. Used internally and possibly in demos; not a supported public concept.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "BlockNavigationAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BlocksListResult", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BookmarkAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BookmarkInfo", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "BoundingRect", + "bucket": "legacy-root", + "rationale": "PE geometry type. Not in core Config but reachable through PE rendering surface. Legacy compat.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CanObject", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ChainableCommandObject", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ChainedCommand", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CollaborationConfig", + "bucket": "supported-root", + "rationale": "Configuration type for a supported feature.", + "confidence": "medium", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CollaborationProvider", + "bucket": "supported-root", + "rationale": "Collaboration/awareness type defined in core/types/index.ts. Customer-facing for collab-provider integrations (e.g., AwarenessState types the documented onAwarenessUpdate callback).", + "confidence": "medium", + "source": "collab", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Command", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommandProps", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Comment", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentConfig", + "bucket": "supported-root", + "rationale": "Configuration type for a supported feature.", + "confidence": "medium", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentElement", + "bucket": "supported-root", + "rationale": "Comments/track-changes type used by Document API consumers.", + "confidence": "medium", + "source": "comments-track", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentLocationsPayload", + "bucket": "supported-root", + "rationale": "Comments/track-changes type used by Document API consumers.", + "confidence": "medium", + "source": "comments-track", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentsPayload", + "bucket": "supported-root", + "rationale": "Comments/track-changes type used by Document API consumers.", + "confidence": "medium", + "source": "comments-track", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CommentsPluginKey", + "bucket": "legacy-root", + "rationale": "ProseMirror PluginKey for comments plugin state. Document API comments.* covers the higher-level use cases; the PluginKey is the lower-level access.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "CommentsType", + "bucket": "supported-root", + "rationale": "Comments/track-changes type used by Document API consumers.", + "confidence": "medium", + "source": "comments-track", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Config", + "bucket": "supported-root", + "rationale": "Configuration type for a supported feature.", + "confidence": "medium", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ContextMenu", + "bucket": "legacy-root", + "rationale": "Legacy component. superdoc/ui exports ContextMenu controller types (ContextMenuContribution, ContextMenuItem) but not a replacement component.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "ContextMenuConfig", + "bucket": "legacy-root", + "rationale": "Configuration type for a feature with legacy surface (paired with a legacy component or older API).", + "confidence": "medium", + "source": "config-legacy", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ContextMenuContext", + "bucket": "legacy-root", + "rationale": "ContextMenu component-side type. Paired with the ContextMenu component (legacy-root).", + "confidence": "medium", + "source": "context-menu", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ContextMenuItem", + "bucket": "legacy-root", + "rationale": "ContextMenu component-side type. Paired with the ContextMenu component (legacy-root).", + "confidence": "medium", + "source": "context-menu", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ContextMenuSection", + "bucket": "legacy-root", + "rationale": "ContextMenu component-side type. Paired with the ContextMenu component (legacy-root).", + "confidence": "medium", + "source": "context-menu", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "CoreCommandMap", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DOCX", + "bucket": "supported-root", + "rationale": "Content-format constant. Heavily documented (133 doc mentions). Customer-facing.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "DirectSurfaceRequest", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocRange", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocumentApi", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocumentMode", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocumentProtectionState", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocxFileEntry", + "bucket": "supported-root", + "rationale": "Document conversion shape used in public APIs.", + "confidence": "low", + "source": "conversion-shape", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "DocxZipper", + "bucket": "legacy-root", + "rationale": "Legacy converter family entry. Same posture as ./docx-zipper subpath (package-boundaries Decision 1).", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "Editor", + "bucket": "supported-root", + "rationale": "Wrapper class for the editor instance. Deprecated members are editor.commands/state/view (use Document API via editor.doc instead), not the class itself.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "EditorCommands", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorEventMap", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorExtension", + "bucket": "legacy-root", + "rationale": "Extension type. Extension helpers (defineNode/defineMark) are supported; this base type itself is under-documented.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorLifecycleState", + "bucket": "supported-root", + "rationale": "Lifecycle state enum on the Editor class. Customer-facing for tracking editor state.", + "confidence": "medium", + "source": "lifecycle", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorOptions", + "bucket": "legacy-root", + "rationale": "ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API.", + "confidence": "high", + "source": "pm-internal", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorState", + "bucket": "legacy-root", + "rationale": "ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API.", + "confidence": "high", + "source": "pm-internal", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorSurface", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorTransactionEvent", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorUpdateEvent", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EditorView", + "bucket": "legacy-root", + "rationale": "ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API.", + "confidence": "high", + "source": "pm-internal", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "EntityAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExportDocxParams", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExportFormat", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExportOptions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExportParams", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExportType", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExtensionCommandMap", + "bucket": "legacy-root", + "rationale": "Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat.", + "confidence": "high", + "source": "commands", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Extensions", + "bucket": "supported-root", + "rationale": "Advanced extension API for authors defining custom nodes/marks. Not a generic first-class embed API. Default programmatic work is Document API; extension authors still need this.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "ExternalPopoverRenderContext", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ExternalSurfaceRenderContext", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FieldValue", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FindReplaceConfig", + "bucket": "legacy-root", + "rationale": "Configuration type for a feature with legacy surface (paired with a legacy component or older API).", + "confidence": "medium", + "source": "config-legacy", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FindReplaceContext", + "bucket": "supported-root", + "rationale": "FindReplace surface API type. Public.", + "confidence": "medium", + "source": "find-replace", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FindReplaceHandle", + "bucket": "supported-root", + "rationale": "FindReplace surface API type. Public.", + "confidence": "medium", + "source": "find-replace", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FindReplaceRenderContext", + "bucket": "supported-root", + "rationale": "FindReplace surface API type. Public.", + "confidence": "medium", + "source": "find-replace", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FindReplaceResolution", + "bucket": "supported-root", + "rationale": "FindReplace surface API type. Public.", + "confidence": "medium", + "source": "find-replace", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FlowBlock", + "bucket": "legacy-root", + "rationale": "In LayoutState.blocks. Layout-engine raw type that must not appear in public .d.ts per package-boundaries.md:64 โ€” already leaks via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FlowMode", + "bucket": "legacy-root", + "rationale": "Types LayoutEngineOptions.flowMode (PE constructor). Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FontConfig", + "bucket": "supported-root", + "rationale": "Configuration type for a supported feature.", + "confidence": "medium", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "FontsResolvedPayload", + "bucket": "supported-root", + "rationale": "Types the documented onFontsResolved callback (apps/docs/editor/superdoc/events.mdx) and appears in core/types/index.ts. Public callback payload despite originating in layout-internal code.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "HTML", + "bucket": "supported-root", + "rationale": "Content-format constant. Heavily used (85 docs, 204 demos). Customer-facing.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "ImageDeselectedEvent", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ImageSelectedEvent", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "IntentSurfaceRequest", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Layout", + "bucket": "legacy-root", + "rationale": "In PE.onLayoutUpdated payload (LayoutState & { layout: Layout; ... }). Layout-engine raw type that must not appear in public .d.ts per package-boundaries.md:64 โ€” already leaks via PE legacy API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutEngineOptions", + "bucket": "legacy-root", + "rationale": "Types PresentationEditorOptions.layoutEngineOptions. Legacy via PE constructor closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutError", + "bucket": "legacy-root", + "rationale": "Param/return of PE.onLayoutError / PE.getLayoutError. Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutFragment", + "bucket": "legacy-root", + "rationale": "Part of LayoutPage shape; transitively required by PE.getPages() closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutMetrics", + "bucket": "legacy-root", + "rationale": "Optional in PE.onLayoutUpdated payload. Layout-engine raw; legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutMode", + "bucket": "legacy-root", + "rationale": "Param type of PresentationEditor.setLayoutMode (line 2940). Imported from @superdoc/painter-dom. Layout-engine raw type leaked through legacy PE API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutPage", + "bucket": "legacy-root", + "rationale": "Return type of PresentationEditor.getPages() (line 1948); customer-scenario.ts:406 uses LayoutPage[]. Raw layout contract leaked through legacy PE API; keep typed for compat, replace with narrower API later.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutState", + "bucket": "legacy-root", + "rationale": "Payload of PresentationEditor.onLayoutUpdated (line 1932). Raw impl state leaked through legacy PE API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LayoutUpdatePayload", + "bucket": "internal-candidate", + "rationale": "Layout engine update payload. PE-internal; NOT used in any public Editor/PE method signature (the closure goes through `LayoutState & { layout; metrics? }`, not this named alias).", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LinkPopoverContext", + "bucket": "supported-root", + "rationale": "LinkPopover surface API type. Public.", + "confidence": "medium", + "source": "link-popover", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LinkPopoverResolution", + "bucket": "supported-root", + "rationale": "LinkPopover surface API type. Public.", + "confidence": "medium", + "source": "link-popover", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "LinkPopoverResolver", + "bucket": "supported-root", + "rationale": "LinkPopover surface API type. Public.", + "confidence": "medium", + "source": "link-popover", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ListDefinitionsPayload", + "bucket": "legacy-root", + "rationale": "Types EditorEventMap.list-definitions-change (EditorEvents.ts:195) AND EditorConfig.onListDefinitionsChange (EditorConfig.ts:564). Legacy via Editor closure; root Config.onListDefinitionsChange is currently `{}` and docs do not advertise the payload shape.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Measure", + "bucket": "legacy-root", + "rationale": "In LayoutState.measures. Layout-engine measurement type; legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Modules", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "NavigableAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "OpenOptions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PDF", + "bucket": "supported-root", + "rationale": "Content-format constant. Customer-facing import/export selector.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "PageMargins", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PageSize", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PageStyles", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PaginationPayload", + "bucket": "legacy-root", + "rationale": "Types EditorEventMap.paginationUpdate (EditorEvents.ts:186). Editor extends EventEmitter. Legacy via Editor closure; SuperDocs documented pagination event has a different shape ({totalPages, superdoc}).", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PaintSnapshot", + "bucket": "legacy-root", + "rationale": "Return type of PresentationEditor.getPaintSnapshot() (line 2861). Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PartChangedEvent", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PartId", + "bucket": "legacy-root", + "rationale": "Header/footer part addressing. OOXML part internal; legacy compat unless public custom-XML/header-footer APIs require it.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PartSectionId", + "bucket": "legacy-root", + "rationale": "Companion to PartId; same posture.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptAttemptResult", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptConfig", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptContext", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptHandle", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptRenderContext", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PasswordPromptResolution", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PermissionParams", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PositionHit", + "bucket": "legacy-root", + "rationale": "PE positioning type. Legacy compat, no docs.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PresenceOptions", + "bucket": "legacy-root", + "rationale": "PE presence API surface type. Legacy via PE closure. (Presence feature is documented; type name itself is not.)", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "PresentationEditor", + "bucket": "legacy-root", + "rationale": "Architecture-facing visual rendering bridge (per CLAUDE.md). Used by advanced/headless surfaces but not the recommended public API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "PresentationEditorOptions", + "bucket": "legacy-root", + "rationale": "Paired with PresentationEditor (legacy-root). Same posture.", + "confidence": "high", + "source": "presentation-editor-paired", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingCapabilities", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingCheckRequest", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingCheckResult", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingConfig", + "bucket": "supported-root", + "rationale": "Configuration type for a supported feature.", + "confidence": "medium", + "source": "config-supported", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingError", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingIssue", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingIssueKind", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingProvider", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingSegment", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingSegmentMetadata", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProofingStatus", + "bucket": "supported-root", + "rationale": "Proofing module type. Public for proofing-provider integrations.", + "confidence": "medium", + "source": "proofing", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProseMirrorJSON", + "bucket": "legacy-root", + "rationale": "Type of Config.jsonOverride (EditorConfig.ts:445). Already @deprecated in source (use ProseMirrorJSONNode).", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ProtectionChangeSource", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "RangeRect", + "bucket": "legacy-root", + "rationale": "Return type of PresentationEditor.getSelectionRects(): RangeRect[]. Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "RemoteCursorState", + "bucket": "legacy-root", + "rationale": "PE awareness/remote-cursor API surface type. Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "RemoteCursorsRenderPayload", + "bucket": "internal-candidate", + "rationale": "PresentationEditor render-payload event. PE-internal; not in any public PE method signature.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "RemoteUserInfo", + "bucket": "legacy-root", + "rationale": "PE awareness/remote-cursor API surface type. Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ResolveRangeOutput", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ResolvedFindReplaceTexts", + "bucket": "supported-root", + "rationale": "FindReplace surface API type. Public.", + "confidence": "medium", + "source": "find-replace", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ResolvedPasswordPromptTexts", + "bucket": "supported-root", + "rationale": "PasswordPrompt surface API type. Public.", + "confidence": "medium", + "source": "password-prompt", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SaveOptions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Schema", + "bucket": "legacy-root", + "rationale": "ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API.", + "confidence": "high", + "source": "pm-internal", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ScrollIntoViewInput", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ScrollIntoViewOutput", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SearchMatch", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SectionHelpers", + "bucket": "internal-candidate", + "rationale": "Implementation helper in packages/super-editor/.../document-section/helpers.js, used by structured-content internals.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SectionMetadata", + "bucket": "legacy-root", + "rationale": "Return-type member of PresentationEditor.getLayoutSnapshot() (line 2744): { layout, blocks, measures, sectionMetadata: SectionMetadata[] }. Legacy via PE closure; caught by the SD-3212 a1b closure gate after manual analysis missed it.", + "confidence": "high", + "source": "closure-gate-promoted", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SelectionApi", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SelectionCommandContext", + "bucket": "supported-root", + "rationale": "Selection API helper type used in command/handle contexts.", + "confidence": "medium", + "source": "selection", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SelectionCurrentInput", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SelectionHandle", + "bucket": "supported-root", + "rationale": "Selection API helper type used in command/handle contexts.", + "confidence": "medium", + "source": "selection", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SelectionInfo", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SlashMenu", + "bucket": "legacy-root", + "rationale": "Legacy component. Sparse public evidence (0 docs, 0 examples, 1 demo, 1 fixture) but currently typed.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "StoryLocator", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperConverter", + "bucket": "legacy-root", + "rationale": "Legacy converter family entry. Same posture as ./converter subpath.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SuperDoc", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SuperDocLayoutEngineOptions", + "bucket": "supported-root", + "rationale": "Types Config.layoutEngineOptions at core/types/index.ts:1350,1505. Documented Config field.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocTelemetryConfig", + "bucket": "supported-root", + "rationale": "Backs Config.telemetry; documented at apps/docs/resources/telemetry.mdx (enabled/endpoint/metadata/licenseKey).", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperEditor", + "bucket": "legacy-root", + "rationale": "Older naming, predates SuperDoc as the canonical entry. Keep compiling; new code should use SuperDoc.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SuperInput", + "bucket": "legacy-root", + "rationale": "Internal/comment-input component. Companion to SuperDoc but not advertised as a separate entry.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SuperToolbar", + "bucket": "legacy-root", + "rationale": "Legacy toolbar implementation. Future custom UI path is superdoc/ui (controller types), but no SuperToolbar replacement component exists today.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "SurfaceComponentProps", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceFloatingPlacement", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceHandle", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceMode", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceOutcome", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceRequest", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceResolution", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfaceResolver", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SurfacesModuleConfig", + "bucket": "supported-root", + "rationale": "Headless Surface API type. Public extension surface for custom UI integrations.", + "confidence": "medium", + "source": "surface", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TelemetryEvent", + "bucket": "internal-candidate", + "rationale": "PresentationEditor layout/error/remoteCursorsRender event union. Source file marks adjacent types as \"Internal Types\". No public docs.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TextAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TextSegment", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TextTarget", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Toolbar", + "bucket": "legacy-root", + "rationale": "Same family as SuperToolbar. Higher docs presence (35) makes removal more breaking.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "TrackChangesBasePluginKey", + "bucket": "legacy-root", + "rationale": "ProseMirror PluginKey for track-changes plugin state. trackChanges.* Document API ops (partial coverage) are the higher-level alternative.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "TrackChangesModuleConfig", + "bucket": "supported-root", + "rationale": "Module config for track-changes (modules.trackChanges). Documented at the module-config layer.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TrackedChangeAddress", + "bucket": "supported-root", + "rationale": "Document API navigation/address/selection type. Promoted into the root facade by SD-3185.", + "confidence": "high", + "source": "doc-api", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TrackedChangesMode", + "bucket": "supported-root", + "rationale": "Comments/track-changes type used by Document API consumers.", + "confidence": "medium", + "source": "comments-track", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "TrackedChangesOverrides", + "bucket": "legacy-root", + "rationale": "Param type of PresentationEditor.setTrackedChangesOverrides (line 1859). Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "Transaction", + "bucket": "legacy-root", + "rationale": "ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API.", + "confidence": "high", + "source": "pm-internal", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "UnsupportedContentItem", + "bucket": "supported-root", + "rationale": "Document conversion shape used in public APIs.", + "confidence": "low", + "source": "conversion-shape", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "UpgradeToCollaborationOptions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "User", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ViewLayout", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ViewOptions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "ViewingVisibilityConfig", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "VirtualizationOptions", + "bucket": "legacy-root", + "rationale": "Types fields in PresentationEditorOptions. Legacy via PE closure.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "assertNodeType", + "bucket": "supported-root", + "rationale": "Runtime assertion helper paired with isNodeType. Customer-facing.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "buildTheme", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "compareVersions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "createTheme", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "createZip", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "defineMark", + "bucket": "supported-root", + "rationale": "Runtime helper for defining custom ProseMirror marks. superdoc/types is type-only and cannot replace.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "defineNode", + "bucket": "supported-root", + "rationale": "Runtime helper for defining custom ProseMirror nodes. superdoc/types is type-only and cannot replace.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "fieldAnnotationHelpers", + "bucket": "legacy-root", + "rationale": "Documented at apps/docs/extensions/field-annotation.mdx and demos/fields/src/App.vue. Real public surface today; should migrate after SD-3192 decides fieldAnnotations.* Document API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getActiveFormatting", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getAllowedImageDimensions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getFileObject", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getMarksFromSelection", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getRichTextExtensions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getSchemaIntrospection", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "getStarterExtensions", + "bucket": "supported-root", + "rationale": "Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities.", + "confidence": "medium", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "isMarkType", + "bucket": "supported-root", + "rationale": "Runtime type guard for mark-type predicates. Customer-facing schema introspection helper.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "isNodeType", + "bucket": "supported-root", + "rationale": "Runtime type guard for node-type predicates. Customer-facing schema introspection helper.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "registeredHandlers", + "bucket": "internal-candidate", + "rationale": "Registry side-effect; 0 docs, 0 examples. Not customer-facing API.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "superEditorHelpers", + "bucket": "internal-candidate", + "rationale": "Helper namespace bag. 0 docs, 0 examples. Likely accidental export.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + }, + { + "name": "trackChangesHelpers", + "bucket": "internal-candidate", + "rationale": "Track-changes helpers. Document API trackChanges.* has partial coverage; helpers are the lower-level access; no public docs.", + "confidence": "high", + "source": "locked", + "inDts": true, + "inDcts": true, + "inEsm": true, + "inCjs": true + } + ] +} diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.md b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md new file mode 100644 index 0000000000..d5a1db2fc1 --- /dev/null +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md @@ -0,0 +1,233 @@ +# SD-3212 A1 โ€” root classification + +Generated: 2026-05-19T11:33:50.546Z +Input: tests/consumer-typecheck/snapshots/superdoc-root-exports.json (200 names, locked baseline) + +## Summary + +| Bucket | Count | +|---|---| +| supported-root | 132 | +| legacy-root | 60 | +| move-to-subpath | 0 | +| internal-candidate | 8 | +| NEEDS-REVIEW | 0 | +| **total** | **200** | + +Confidence: high=98, medium=100, needs-review=0. + +## supported-root (132) + +| Name | Confidence | Source | Rationale | +|---|---|---|---| +| `AwarenessState` | medium | collab | Collaboration/awareness type defined in core/types/index.ts. Customer-facing for collab-provider integrations (e.g., AwarenessState types the documented onAwarenessUpdate callback). | +| `BinaryData` | high | locked | Shape of binary content used in documented import/export/open/save paths. Type-reachable through documented APIs. | +| `BlockNavigationAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `BlocksListResult` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `BookmarkAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `BookmarkInfo` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `CollaborationConfig` | medium | config-supported | Configuration type for a supported feature. | +| `CollaborationProvider` | medium | collab | Collaboration/awareness type defined in core/types/index.ts. Customer-facing for collab-provider integrations (e.g., AwarenessState types the documented onAwarenessUpdate callback). | +| `Comment` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `CommentAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `CommentConfig` | medium | config-supported | Configuration type for a supported feature. | +| `CommentElement` | medium | comments-track | Comments/track-changes type used by Document API consumers. | +| `CommentLocationsPayload` | medium | comments-track | Comments/track-changes type used by Document API consumers. | +| `CommentsPayload` | medium | comments-track | Comments/track-changes type used by Document API consumers. | +| `CommentsType` | medium | comments-track | Comments/track-changes type used by Document API consumers. | +| `Config` | medium | config-supported | Configuration type for a supported feature. | +| `DOCX` | high | locked | Content-format constant. Heavily documented (133 doc mentions). Customer-facing. | +| `DirectSurfaceRequest` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `DocRange` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `DocumentApi` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `DocumentMode` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `DocumentProtectionState` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `DocxFileEntry` | low | conversion-shape | Document conversion shape used in public APIs. | +| `Editor` | high | locked | Wrapper class for the editor instance. Deprecated members are editor.commands/state/view (use Document API via editor.doc instead), not the class itself. | +| `EditorEventMap` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `EditorLifecycleState` | medium | lifecycle | Lifecycle state enum on the Editor class. Customer-facing for tracking editor state. | +| `EditorSurface` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `EditorTransactionEvent` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `EditorUpdateEvent` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `EntityAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `ExportDocxParams` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ExportFormat` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ExportOptions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ExportParams` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ExportType` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `Extensions` | high | locked | Advanced extension API for authors defining custom nodes/marks. Not a generic first-class embed API. Default programmatic work is Document API; extension authors still need this. | +| `ExternalPopoverRenderContext` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `ExternalSurfaceRenderContext` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `FieldValue` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `FindReplaceContext` | medium | find-replace | FindReplace surface API type. Public. | +| `FindReplaceHandle` | medium | find-replace | FindReplace surface API type. Public. | +| `FindReplaceRenderContext` | medium | find-replace | FindReplace surface API type. Public. | +| `FindReplaceResolution` | medium | find-replace | FindReplace surface API type. Public. | +| `FontConfig` | medium | config-supported | Configuration type for a supported feature. | +| `FontsResolvedPayload` | high | locked | Types the documented onFontsResolved callback (apps/docs/editor/superdoc/events.mdx) and appears in core/types/index.ts. Public callback payload despite originating in layout-internal code. | +| `HTML` | high | locked | Content-format constant. Heavily used (85 docs, 204 demos). Customer-facing. | +| `ImageDeselectedEvent` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ImageSelectedEvent` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `IntentSurfaceRequest` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `LinkPopoverContext` | medium | link-popover | LinkPopover surface API type. Public. | +| `LinkPopoverResolution` | medium | link-popover | LinkPopover surface API type. Public. | +| `LinkPopoverResolver` | medium | link-popover | LinkPopover surface API type. Public. | +| `Modules` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `NavigableAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `OpenOptions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PDF` | high | locked | Content-format constant. Customer-facing import/export selector. | +| `PageMargins` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PageSize` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PageStyles` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PartChangedEvent` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `PasswordPromptAttemptResult` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PasswordPromptConfig` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PasswordPromptContext` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PasswordPromptHandle` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PasswordPromptRenderContext` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PasswordPromptResolution` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `PermissionParams` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ProofingCapabilities` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingCheckRequest` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingCheckResult` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingConfig` | medium | config-supported | Configuration type for a supported feature. | +| `ProofingError` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingIssue` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingIssueKind` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingProvider` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingSegment` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingSegmentMetadata` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProofingStatus` | medium | proofing | Proofing module type. Public for proofing-provider integrations. | +| `ProtectionChangeSource` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ResolveRangeOutput` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `ResolvedFindReplaceTexts` | medium | find-replace | FindReplace surface API type. Public. | +| `ResolvedPasswordPromptTexts` | medium | password-prompt | PasswordPrompt surface API type. Public. | +| `SaveOptions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ScrollIntoViewInput` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `ScrollIntoViewOutput` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `SearchMatch` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `SelectionApi` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `SelectionCommandContext` | medium | selection | Selection API helper type used in command/handle contexts. | +| `SelectionCurrentInput` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `SelectionHandle` | medium | selection | Selection API helper type used in command/handle contexts. | +| `SelectionInfo` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `StoryLocator` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `SuperDoc` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `SuperDocLayoutEngineOptions` | high | locked | Types Config.layoutEngineOptions at core/types/index.ts:1350,1505. Documented Config field. | +| `SuperDocTelemetryConfig` | high | locked | Backs Config.telemetry; documented at apps/docs/resources/telemetry.mdx (enabled/endpoint/metadata/licenseKey). | +| `SurfaceComponentProps` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceFloatingPlacement` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceHandle` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceMode` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceOutcome` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceRequest` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceResolution` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfaceResolver` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `SurfacesModuleConfig` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | +| `TextAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `TextSegment` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `TextTarget` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `TrackChangesModuleConfig` | high | locked | Module config for track-changes (modules.trackChanges). Documented at the module-config layer. | +| `TrackedChangeAddress` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | +| `TrackedChangesMode` | medium | comments-track | Comments/track-changes type used by Document API consumers. | +| `UnsupportedContentItem` | low | conversion-shape | Document conversion shape used in public APIs. | +| `UpgradeToCollaborationOptions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `User` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ViewLayout` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ViewOptions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `ViewingVisibilityConfig` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `assertNodeType` | high | locked | Runtime assertion helper paired with isNodeType. Customer-facing. | +| `buildTheme` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `compareVersions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `createTheme` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `createZip` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `defineMark` | high | locked | Runtime helper for defining custom ProseMirror marks. superdoc/types is type-only and cannot replace. | +| `defineNode` | high | locked | Runtime helper for defining custom ProseMirror nodes. superdoc/types is type-only and cannot replace. | +| `getActiveFormatting` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getAllowedImageDimensions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getFileObject` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getMarksFromSelection` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getRichTextExtensions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getSchemaIntrospection` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `getStarterExtensions` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `isMarkType` | high | locked | Runtime type guard for mark-type predicates. Customer-facing schema introspection helper. | +| `isNodeType` | high | locked | Runtime type guard for node-type predicates. Customer-facing schema introspection helper. | + +## legacy-root (60) + +| Name | Confidence | Source | Rationale | +|---|---|---|---| +| `AIWriter` | high | locked | Internal Vue component used by AI UI. Real runtime export but no documented standalone import. | +| `BlankDOCX` | high | locked | Runtime-exported empty-DOCX builder. Used internally and possibly in demos; not a supported public concept. | +| `BoundingRect` | high | locked | PE geometry type. Not in core Config but reachable through PE rendering surface. Legacy compat. | +| `CanObject` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `ChainableCommandObject` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `ChainedCommand` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `Command` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `CommandProps` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `CommentsPluginKey` | high | locked | ProseMirror PluginKey for comments plugin state. Document API comments.* covers the higher-level use cases; the PluginKey is the lower-level access. | +| `ContextMenu` | high | locked | Legacy component. superdoc/ui exports ContextMenu controller types (ContextMenuContribution, ContextMenuItem) but not a replacement component. | +| `ContextMenuConfig` | medium | config-legacy | Configuration type for a feature with legacy surface (paired with a legacy component or older API). | +| `ContextMenuContext` | medium | context-menu | ContextMenu component-side type. Paired with the ContextMenu component (legacy-root). | +| `ContextMenuItem` | medium | context-menu | ContextMenu component-side type. Paired with the ContextMenu component (legacy-root). | +| `ContextMenuSection` | medium | context-menu | ContextMenu component-side type. Paired with the ContextMenu component (legacy-root). | +| `CoreCommandMap` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `DocxZipper` | high | locked | Legacy converter family entry. Same posture as ./docx-zipper subpath (package-boundaries Decision 1). | +| `EditorCommands` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `EditorExtension` | high | locked | Extension type. Extension helpers (defineNode/defineMark) are supported; this base type itself is under-documented. | +| `EditorOptions` | high | pm-internal | ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API. | +| `EditorState` | high | pm-internal | ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API. | +| `EditorView` | high | pm-internal | ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API. | +| `ExtensionCommandMap` | high | commands | Editor command typing infrastructure. editor.commands.* is deprecated; Document API (editor.doc.*) is the supported programmatic surface. Keep typed for compat. | +| `FindReplaceConfig` | medium | config-legacy | Configuration type for a feature with legacy surface (paired with a legacy component or older API). | +| `FlowBlock` | high | locked | In LayoutState.blocks. Layout-engine raw type that must not appear in public .d.ts per package-boundaries.md:64 โ€” already leaks via PE closure. | +| `FlowMode` | high | locked | Types LayoutEngineOptions.flowMode (PE constructor). Legacy via PE closure. | +| `Layout` | high | locked | In PE.onLayoutUpdated payload (LayoutState & { layout: Layout; ... }). Layout-engine raw type that must not appear in public .d.ts per package-boundaries.md:64 โ€” already leaks via PE legacy API. | +| `LayoutEngineOptions` | high | locked | Types PresentationEditorOptions.layoutEngineOptions. Legacy via PE constructor closure. | +| `LayoutError` | high | locked | Param/return of PE.onLayoutError / PE.getLayoutError. Legacy via PE closure. | +| `LayoutFragment` | high | locked | Part of LayoutPage shape; transitively required by PE.getPages() closure. | +| `LayoutMetrics` | high | locked | Optional in PE.onLayoutUpdated payload. Layout-engine raw; legacy via PE closure. | +| `LayoutMode` | high | locked | Param type of PresentationEditor.setLayoutMode (line 2940). Imported from @superdoc/painter-dom. Layout-engine raw type leaked through legacy PE API. | +| `LayoutPage` | high | locked | Return type of PresentationEditor.getPages() (line 1948); customer-scenario.ts:406 uses LayoutPage[]. Raw layout contract leaked through legacy PE API; keep typed for compat, replace with narrower API later. | +| `LayoutState` | high | locked | Payload of PresentationEditor.onLayoutUpdated (line 1932). Raw impl state leaked through legacy PE API. | +| `ListDefinitionsPayload` | high | locked | Types EditorEventMap.list-definitions-change (EditorEvents.ts:195) AND EditorConfig.onListDefinitionsChange (EditorConfig.ts:564). Legacy via Editor closure; root Config.onListDefinitionsChange is currently `{}` and docs do not advertise the payload shape. | +| `Measure` | high | locked | In LayoutState.measures. Layout-engine measurement type; legacy via PE closure. | +| `PaginationPayload` | high | locked | Types EditorEventMap.paginationUpdate (EditorEvents.ts:186). Editor extends EventEmitter. Legacy via Editor closure; SuperDocs documented pagination event has a different shape ({totalPages, superdoc}). | +| `PaintSnapshot` | high | locked | Return type of PresentationEditor.getPaintSnapshot() (line 2861). Legacy via PE closure. | +| `PartId` | high | locked | Header/footer part addressing. OOXML part internal; legacy compat unless public custom-XML/header-footer APIs require it. | +| `PartSectionId` | high | locked | Companion to PartId; same posture. | +| `PositionHit` | high | locked | PE positioning type. Legacy compat, no docs. | +| `PresenceOptions` | high | locked | PE presence API surface type. Legacy via PE closure. (Presence feature is documented; type name itself is not.) | +| `PresentationEditor` | high | locked | Architecture-facing visual rendering bridge (per CLAUDE.md). Used by advanced/headless surfaces but not the recommended public API. | +| `PresentationEditorOptions` | high | presentation-editor-paired | Paired with PresentationEditor (legacy-root). Same posture. | +| `ProseMirrorJSON` | high | locked | Type of Config.jsonOverride (EditorConfig.ts:445). Already @deprecated in source (use ProseMirrorJSONNode). | +| `RangeRect` | high | locked | Return type of PresentationEditor.getSelectionRects(): RangeRect[]. Legacy via PE closure. | +| `RemoteCursorState` | high | locked | PE awareness/remote-cursor API surface type. Legacy via PE closure. | +| `RemoteUserInfo` | high | locked | PE awareness/remote-cursor API surface type. Legacy via PE closure. | +| `Schema` | high | pm-internal | ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API. | +| `SectionMetadata` | high | closure-gate-promoted | Return-type member of PresentationEditor.getLayoutSnapshot() (line 2744): { layout, blocks, measures, sectionMetadata: SectionMetadata[] }. Legacy via PE closure; caught by the SD-3212 a1b closure gate after manual analysis missed it. | +| `SlashMenu` | high | locked | Legacy component. Sparse public evidence (0 docs, 0 examples, 1 demo, 1 fixture) but currently typed. | +| `SuperConverter` | high | locked | Legacy converter family entry. Same posture as ./converter subpath. | +| `SuperEditor` | high | locked | Older naming, predates SuperDoc as the canonical entry. Keep compiling; new code should use SuperDoc. | +| `SuperInput` | high | locked | Internal/comment-input component. Companion to SuperDoc but not advertised as a separate entry. | +| `SuperToolbar` | high | locked | Legacy toolbar implementation. Future custom UI path is superdoc/ui (controller types), but no SuperToolbar replacement component exists today. | +| `Toolbar` | high | locked | Same family as SuperToolbar. Higher docs presence (35) makes removal more breaking. | +| `TrackChangesBasePluginKey` | high | locked | ProseMirror PluginKey for track-changes plugin state. trackChanges.* Document API ops (partial coverage) are the higher-level alternative. | +| `TrackedChangesOverrides` | high | locked | Param type of PresentationEditor.setTrackedChangesOverrides (line 1859). Legacy via PE closure. | +| `Transaction` | high | pm-internal | ProseMirror primitive type. Editor state/view/schema/transaction are deprecated direct-access surfaces (CLAUDE.md). Customers should use Document API. | +| `VirtualizationOptions` | high | locked | Types fields in PresentationEditorOptions. Legacy via PE closure. | +| `fieldAnnotationHelpers` | high | locked | Documented at apps/docs/extensions/field-annotation.mdx and demos/fields/src/App.vue. Real public surface today; should migrate after SD-3192 decides fieldAnnotations.* Document API. | + +## internal-candidate (8) + +| Name | Confidence | Source | Rationale | +|---|---|---|---| +| `AnnotatorHelpers` | high | locked | Implementation helper in packages/super-editor/.../helpers/annotator.js, used internally by Editor.ts. No source-side public usage. | +| `LayoutUpdatePayload` | high | locked | Layout engine update payload. PE-internal; NOT used in any public Editor/PE method signature (the closure goes through `LayoutState & { layout; metrics? }`, not this named alias). | +| `RemoteCursorsRenderPayload` | high | locked | PresentationEditor render-payload event. PE-internal; not in any public PE method signature. | +| `SectionHelpers` | high | locked | Implementation helper in packages/super-editor/.../document-section/helpers.js, used by structured-content internals. | +| `TelemetryEvent` | high | locked | PresentationEditor layout/error/remoteCursorsRender event union. Source file marks adjacent types as "Internal Types". No public docs. | +| `registeredHandlers` | high | locked | Registry side-effect; 0 docs, 0 examples. Not customer-facing API. | +| `superEditorHelpers` | high | locked | Helper namespace bag. 0 docs, 0 examples. Likely accidental export. | +| `trackChangesHelpers` | high | locked | Track-changes helpers. Document API trackChanges.* has partial coverage; helpers are the lower-level access; no public docs. | + diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json new file mode 100644 index 0000000000..0d75d4371d --- /dev/null +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -0,0 +1,702 @@ +{ + "generatedAt": "2026-05-18T22:39:27.584Z", + "ticket": "SD-3212 PR A0", + "package": "superdoc", + "rootExport": { + "types": { + "import": "./dist/superdoc/src/index.d.ts", + "require": "./dist/superdoc/src/index.d.cts" + }, + "import": "./dist/superdoc.es.js", + "require": "./dist/superdoc.cjs" + }, + "sources": { + "types.import": { + "path": "./dist/superdoc/src/index.d.ts", + "names": [ + "AIWriter", + "AnnotatorHelpers", + "AwarenessState", + "BinaryData", + "BlankDOCX", + "BlockNavigationAddress", + "BlocksListResult", + "BookmarkAddress", + "BookmarkInfo", + "BoundingRect", + "CanObject", + "ChainableCommandObject", + "ChainedCommand", + "CollaborationConfig", + "CollaborationProvider", + "Command", + "CommandProps", + "Comment", + "CommentAddress", + "CommentConfig", + "CommentElement", + "CommentLocationsPayload", + "CommentsPayload", + "CommentsPluginKey", + "CommentsType", + "Config", + "ContextMenu", + "ContextMenuConfig", + "ContextMenuContext", + "ContextMenuItem", + "ContextMenuSection", + "CoreCommandMap", + "DOCX", + "DirectSurfaceRequest", + "DocRange", + "DocumentApi", + "DocumentMode", + "DocumentProtectionState", + "DocxFileEntry", + "DocxZipper", + "Editor", + "EditorCommands", + "EditorEventMap", + "EditorExtension", + "EditorLifecycleState", + "EditorOptions", + "EditorState", + "EditorSurface", + "EditorTransactionEvent", + "EditorUpdateEvent", + "EditorView", + "EntityAddress", + "ExportDocxParams", + "ExportFormat", + "ExportOptions", + "ExportParams", + "ExportType", + "ExtensionCommandMap", + "Extensions", + "ExternalPopoverRenderContext", + "ExternalSurfaceRenderContext", + "FieldValue", + "FindReplaceConfig", + "FindReplaceContext", + "FindReplaceHandle", + "FindReplaceRenderContext", + "FindReplaceResolution", + "FlowBlock", + "FlowMode", + "FontConfig", + "FontsResolvedPayload", + "HTML", + "ImageDeselectedEvent", + "ImageSelectedEvent", + "IntentSurfaceRequest", + "Layout", + "LayoutEngineOptions", + "LayoutError", + "LayoutFragment", + "LayoutMetrics", + "LayoutMode", + "LayoutPage", + "LayoutState", + "LayoutUpdatePayload", + "LinkPopoverContext", + "LinkPopoverResolution", + "LinkPopoverResolver", + "ListDefinitionsPayload", + "Measure", + "Modules", + "NavigableAddress", + "OpenOptions", + "PDF", + "PageMargins", + "PageSize", + "PageStyles", + "PaginationPayload", + "PaintSnapshot", + "PartChangedEvent", + "PartId", + "PartSectionId", + "PasswordPromptAttemptResult", + "PasswordPromptConfig", + "PasswordPromptContext", + "PasswordPromptHandle", + "PasswordPromptRenderContext", + "PasswordPromptResolution", + "PermissionParams", + "PositionHit", + "PresenceOptions", + "PresentationEditor", + "PresentationEditorOptions", + "ProofingCapabilities", + "ProofingCheckRequest", + "ProofingCheckResult", + "ProofingConfig", + "ProofingError", + "ProofingIssue", + "ProofingIssueKind", + "ProofingProvider", + "ProofingSegment", + "ProofingSegmentMetadata", + "ProofingStatus", + "ProseMirrorJSON", + "ProtectionChangeSource", + "RangeRect", + "RemoteCursorState", + "RemoteCursorsRenderPayload", + "RemoteUserInfo", + "ResolveRangeOutput", + "ResolvedFindReplaceTexts", + "ResolvedPasswordPromptTexts", + "SaveOptions", + "Schema", + "ScrollIntoViewInput", + "ScrollIntoViewOutput", + "SearchMatch", + "SectionHelpers", + "SectionMetadata", + "SelectionApi", + "SelectionCommandContext", + "SelectionCurrentInput", + "SelectionHandle", + "SelectionInfo", + "SlashMenu", + "StoryLocator", + "SuperConverter", + "SuperDoc", + "SuperDocLayoutEngineOptions", + "SuperDocTelemetryConfig", + "SuperEditor", + "SuperInput", + "SuperToolbar", + "SurfaceComponentProps", + "SurfaceFloatingPlacement", + "SurfaceHandle", + "SurfaceMode", + "SurfaceOutcome", + "SurfaceRequest", + "SurfaceResolution", + "SurfaceResolver", + "SurfacesModuleConfig", + "TelemetryEvent", + "TextAddress", + "TextSegment", + "TextTarget", + "Toolbar", + "TrackChangesBasePluginKey", + "TrackChangesModuleConfig", + "TrackedChangeAddress", + "TrackedChangesMode", + "TrackedChangesOverrides", + "Transaction", + "UnsupportedContentItem", + "UpgradeToCollaborationOptions", + "User", + "ViewLayout", + "ViewOptions", + "ViewingVisibilityConfig", + "VirtualizationOptions", + "assertNodeType", + "buildTheme", + "compareVersions", + "createTheme", + "createZip", + "defineMark", + "defineNode", + "fieldAnnotationHelpers", + "getActiveFormatting", + "getAllowedImageDimensions", + "getFileObject", + "getMarksFromSelection", + "getRichTextExtensions", + "getSchemaIntrospection", + "getStarterExtensions", + "isMarkType", + "isNodeType", + "registeredHandlers", + "superEditorHelpers", + "trackChangesHelpers" + ], + "error": null + }, + "types.require": { + "path": "./dist/superdoc/src/index.d.cts", + "names": [ + "AIWriter", + "AnnotatorHelpers", + "AwarenessState", + "BinaryData", + "BlankDOCX", + "BlockNavigationAddress", + "BlocksListResult", + "BookmarkAddress", + "BookmarkInfo", + "BoundingRect", + "CanObject", + "ChainableCommandObject", + "ChainedCommand", + "CollaborationConfig", + "CollaborationProvider", + "Command", + "CommandProps", + "Comment", + "CommentAddress", + "CommentConfig", + "CommentElement", + "CommentLocationsPayload", + "CommentsPayload", + "CommentsPluginKey", + "CommentsType", + "Config", + "ContextMenu", + "ContextMenuConfig", + "ContextMenuContext", + "ContextMenuItem", + "ContextMenuSection", + "CoreCommandMap", + "DOCX", + "DirectSurfaceRequest", + "DocRange", + "DocumentApi", + "DocumentMode", + "DocumentProtectionState", + "DocxFileEntry", + "DocxZipper", + "Editor", + "EditorCommands", + "EditorEventMap", + "EditorExtension", + "EditorLifecycleState", + "EditorOptions", + "EditorState", + "EditorSurface", + "EditorTransactionEvent", + "EditorUpdateEvent", + "EditorView", + "EntityAddress", + "ExportDocxParams", + "ExportFormat", + "ExportOptions", + "ExportParams", + "ExportType", + "ExtensionCommandMap", + "Extensions", + "ExternalPopoverRenderContext", + "ExternalSurfaceRenderContext", + "FieldValue", + "FindReplaceConfig", + "FindReplaceContext", + "FindReplaceHandle", + "FindReplaceRenderContext", + "FindReplaceResolution", + "FlowBlock", + "FlowMode", + "FontConfig", + "FontsResolvedPayload", + "HTML", + "ImageDeselectedEvent", + "ImageSelectedEvent", + "IntentSurfaceRequest", + "Layout", + "LayoutEngineOptions", + "LayoutError", + "LayoutFragment", + "LayoutMetrics", + "LayoutMode", + "LayoutPage", + "LayoutState", + "LayoutUpdatePayload", + "LinkPopoverContext", + "LinkPopoverResolution", + "LinkPopoverResolver", + "ListDefinitionsPayload", + "Measure", + "Modules", + "NavigableAddress", + "OpenOptions", + "PDF", + "PageMargins", + "PageSize", + "PageStyles", + "PaginationPayload", + "PaintSnapshot", + "PartChangedEvent", + "PartId", + "PartSectionId", + "PasswordPromptAttemptResult", + "PasswordPromptConfig", + "PasswordPromptContext", + "PasswordPromptHandle", + "PasswordPromptRenderContext", + "PasswordPromptResolution", + "PermissionParams", + "PositionHit", + "PresenceOptions", + "PresentationEditor", + "PresentationEditorOptions", + "ProofingCapabilities", + "ProofingCheckRequest", + "ProofingCheckResult", + "ProofingConfig", + "ProofingError", + "ProofingIssue", + "ProofingIssueKind", + "ProofingProvider", + "ProofingSegment", + "ProofingSegmentMetadata", + "ProofingStatus", + "ProseMirrorJSON", + "ProtectionChangeSource", + "RangeRect", + "RemoteCursorState", + "RemoteCursorsRenderPayload", + "RemoteUserInfo", + "ResolveRangeOutput", + "ResolvedFindReplaceTexts", + "ResolvedPasswordPromptTexts", + "SaveOptions", + "Schema", + "ScrollIntoViewInput", + "ScrollIntoViewOutput", + "SearchMatch", + "SectionHelpers", + "SectionMetadata", + "SelectionApi", + "SelectionCommandContext", + "SelectionCurrentInput", + "SelectionHandle", + "SelectionInfo", + "SlashMenu", + "StoryLocator", + "SuperConverter", + "SuperDoc", + "SuperDocLayoutEngineOptions", + "SuperDocTelemetryConfig", + "SuperEditor", + "SuperInput", + "SuperToolbar", + "SurfaceComponentProps", + "SurfaceFloatingPlacement", + "SurfaceHandle", + "SurfaceMode", + "SurfaceOutcome", + "SurfaceRequest", + "SurfaceResolution", + "SurfaceResolver", + "SurfacesModuleConfig", + "TelemetryEvent", + "TextAddress", + "TextSegment", + "TextTarget", + "Toolbar", + "TrackChangesBasePluginKey", + "TrackChangesModuleConfig", + "TrackedChangeAddress", + "TrackedChangesMode", + "TrackedChangesOverrides", + "Transaction", + "UnsupportedContentItem", + "UpgradeToCollaborationOptions", + "User", + "ViewLayout", + "ViewOptions", + "ViewingVisibilityConfig", + "VirtualizationOptions", + "assertNodeType", + "buildTheme", + "compareVersions", + "createTheme", + "createZip", + "defineMark", + "defineNode", + "fieldAnnotationHelpers", + "getActiveFormatting", + "getAllowedImageDimensions", + "getFileObject", + "getMarksFromSelection", + "getRichTextExtensions", + "getSchemaIntrospection", + "getStarterExtensions", + "isMarkType", + "isNodeType", + "registeredHandlers", + "superEditorHelpers", + "trackChangesHelpers" + ], + "error": null + }, + "import": { + "path": "./dist/superdoc.es.js", + "names": [ + "AIWriter", + "AnnotatorHelpers", + "BlankDOCX", + "CommentsPluginKey", + "ContextMenu", + "DOCX", + "DocxZipper", + "Editor", + "Extensions", + "HTML", + "PDF", + "PresentationEditor", + "SectionHelpers", + "SlashMenu", + "SuperConverter", + "SuperDoc", + "SuperEditor", + "SuperInput", + "SuperToolbar", + "Toolbar", + "TrackChangesBasePluginKey", + "assertNodeType", + "buildTheme", + "compareVersions", + "createTheme", + "createZip", + "defineMark", + "defineNode", + "fieldAnnotationHelpers", + "getActiveFormatting", + "getAllowedImageDimensions", + "getFileObject", + "getMarksFromSelection", + "getRichTextExtensions", + "getSchemaIntrospection", + "getStarterExtensions", + "isMarkType", + "isNodeType", + "registeredHandlers", + "superEditorHelpers", + "trackChangesHelpers" + ], + "error": null + }, + "require": { + "path": "./dist/superdoc.cjs", + "names": [ + "AIWriter", + "AnnotatorHelpers", + "BlankDOCX", + "CommentsPluginKey", + "ContextMenu", + "DOCX", + "DocxZipper", + "Editor", + "Extensions", + "HTML", + "PDF", + "PresentationEditor", + "SectionHelpers", + "SlashMenu", + "SuperConverter", + "SuperDoc", + "SuperEditor", + "SuperInput", + "SuperToolbar", + "Toolbar", + "TrackChangesBasePluginKey", + "assertNodeType", + "buildTheme", + "compareVersions", + "createTheme", + "createZip", + "defineMark", + "defineNode", + "fieldAnnotationHelpers", + "getActiveFormatting", + "getAllowedImageDimensions", + "getFileObject", + "getMarksFromSelection", + "getRichTextExtensions", + "getSchemaIntrospection", + "getStarterExtensions", + "isMarkType", + "isNodeType", + "registeredHandlers", + "superEditorHelpers", + "trackChangesHelpers" + ], + "error": null + } + }, + "counts": { + "types.import": 200, + "types.require": 200, + "import": 41, + "require": 41, + "union": 200 + }, + "divergences": { + "typesImportVsRequire": { + "onlyInImport": [], + "onlyInRequire": [] + }, + "esmVsCjs": { + "onlyInEsm": [], + "onlyInCjs": [] + }, + "typesVsRuntime": { + "typedOnly": [ + "AwarenessState", + "BinaryData", + "BlockNavigationAddress", + "BlocksListResult", + "BookmarkAddress", + "BookmarkInfo", + "BoundingRect", + "CanObject", + "ChainableCommandObject", + "ChainedCommand", + "CollaborationConfig", + "CollaborationProvider", + "Command", + "CommandProps", + "Comment", + "CommentAddress", + "CommentConfig", + "CommentElement", + "CommentLocationsPayload", + "CommentsPayload", + "CommentsType", + "Config", + "ContextMenuConfig", + "ContextMenuContext", + "ContextMenuItem", + "ContextMenuSection", + "CoreCommandMap", + "DirectSurfaceRequest", + "DocRange", + "DocumentApi", + "DocumentMode", + "DocumentProtectionState", + "DocxFileEntry", + "EditorCommands", + "EditorEventMap", + "EditorExtension", + "EditorLifecycleState", + "EditorOptions", + "EditorState", + "EditorSurface", + "EditorTransactionEvent", + "EditorUpdateEvent", + "EditorView", + "EntityAddress", + "ExportDocxParams", + "ExportFormat", + "ExportOptions", + "ExportParams", + "ExportType", + "ExtensionCommandMap", + "ExternalPopoverRenderContext", + "ExternalSurfaceRenderContext", + "FieldValue", + "FindReplaceConfig", + "FindReplaceContext", + "FindReplaceHandle", + "FindReplaceRenderContext", + "FindReplaceResolution", + "FlowBlock", + "FlowMode", + "FontConfig", + "FontsResolvedPayload", + "ImageDeselectedEvent", + "ImageSelectedEvent", + "IntentSurfaceRequest", + "Layout", + "LayoutEngineOptions", + "LayoutError", + "LayoutFragment", + "LayoutMetrics", + "LayoutMode", + "LayoutPage", + "LayoutState", + "LayoutUpdatePayload", + "LinkPopoverContext", + "LinkPopoverResolution", + "LinkPopoverResolver", + "ListDefinitionsPayload", + "Measure", + "Modules", + "NavigableAddress", + "OpenOptions", + "PageMargins", + "PageSize", + "PageStyles", + "PaginationPayload", + "PaintSnapshot", + "PartChangedEvent", + "PartId", + "PartSectionId", + "PasswordPromptAttemptResult", + "PasswordPromptConfig", + "PasswordPromptContext", + "PasswordPromptHandle", + "PasswordPromptRenderContext", + "PasswordPromptResolution", + "PermissionParams", + "PositionHit", + "PresenceOptions", + "PresentationEditorOptions", + "ProofingCapabilities", + "ProofingCheckRequest", + "ProofingCheckResult", + "ProofingConfig", + "ProofingError", + "ProofingIssue", + "ProofingIssueKind", + "ProofingProvider", + "ProofingSegment", + "ProofingSegmentMetadata", + "ProofingStatus", + "ProseMirrorJSON", + "ProtectionChangeSource", + "RangeRect", + "RemoteCursorState", + "RemoteCursorsRenderPayload", + "RemoteUserInfo", + "ResolveRangeOutput", + "ResolvedFindReplaceTexts", + "ResolvedPasswordPromptTexts", + "SaveOptions", + "Schema", + "ScrollIntoViewInput", + "ScrollIntoViewOutput", + "SearchMatch", + "SectionMetadata", + "SelectionApi", + "SelectionCommandContext", + "SelectionCurrentInput", + "SelectionHandle", + "SelectionInfo", + "StoryLocator", + "SuperDocLayoutEngineOptions", + "SuperDocTelemetryConfig", + "SurfaceComponentProps", + "SurfaceFloatingPlacement", + "SurfaceHandle", + "SurfaceMode", + "SurfaceOutcome", + "SurfaceRequest", + "SurfaceResolution", + "SurfaceResolver", + "SurfacesModuleConfig", + "TelemetryEvent", + "TextAddress", + "TextSegment", + "TextTarget", + "TrackChangesModuleConfig", + "TrackedChangeAddress", + "TrackedChangesMode", + "TrackedChangesOverrides", + "Transaction", + "UnsupportedContentItem", + "UpgradeToCollaborationOptions", + "User", + "ViewLayout", + "ViewOptions", + "ViewingVisibilityConfig", + "VirtualizationOptions" + ], + "runtimeOnly": [] + } + } +} diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md new file mode 100644 index 0000000000..2cb8aeb677 --- /dev/null +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -0,0 +1,390 @@ +# superdoc root export inventory (SD-3212 PR A0) + +Generated: 2026-05-18T22:39:27.584Z +Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` + +## Counts + +| Source | Path | Count | +|---|---|---| +| types.import | `./dist/superdoc/src/index.d.ts` | 200 | +| types.require | `./dist/superdoc/src/index.d.cts` | 200 | +| import | `./dist/superdoc.es.js` | 41 | +| require | `./dist/superdoc.cjs` | 41 | +| **union** | | **200** | + +## Divergences + +- types.import only (not in types.require): 0 +- types.require only (not in types.import): 0 +- ESM only (not in CJS): 0 +- CJS only (not in ESM): 0 +- typed but no runtime export (phantom risk): 159 +- runtime export but not typed (silent shadow on root): 0 + +### Type-only names (no runtime) + +- `AwarenessState` +- `BinaryData` +- `BlockNavigationAddress` +- `BlocksListResult` +- `BookmarkAddress` +- `BookmarkInfo` +- `BoundingRect` +- `CanObject` +- `ChainableCommandObject` +- `ChainedCommand` +- `CollaborationConfig` +- `CollaborationProvider` +- `Command` +- `CommandProps` +- `Comment` +- `CommentAddress` +- `CommentConfig` +- `CommentElement` +- `CommentLocationsPayload` +- `CommentsPayload` +- `CommentsType` +- `Config` +- `ContextMenuConfig` +- `ContextMenuContext` +- `ContextMenuItem` +- `ContextMenuSection` +- `CoreCommandMap` +- `DirectSurfaceRequest` +- `DocRange` +- `DocumentApi` +- `DocumentMode` +- `DocumentProtectionState` +- `DocxFileEntry` +- `EditorCommands` +- `EditorEventMap` +- `EditorExtension` +- `EditorLifecycleState` +- `EditorOptions` +- `EditorState` +- `EditorSurface` +- `EditorTransactionEvent` +- `EditorUpdateEvent` +- `EditorView` +- `EntityAddress` +- `ExportDocxParams` +- `ExportFormat` +- `ExportOptions` +- `ExportParams` +- `ExportType` +- `ExtensionCommandMap` +- `ExternalPopoverRenderContext` +- `ExternalSurfaceRenderContext` +- `FieldValue` +- `FindReplaceConfig` +- `FindReplaceContext` +- `FindReplaceHandle` +- `FindReplaceRenderContext` +- `FindReplaceResolution` +- `FlowBlock` +- `FlowMode` +- `FontConfig` +- `FontsResolvedPayload` +- `ImageDeselectedEvent` +- `ImageSelectedEvent` +- `IntentSurfaceRequest` +- `Layout` +- `LayoutEngineOptions` +- `LayoutError` +- `LayoutFragment` +- `LayoutMetrics` +- `LayoutMode` +- `LayoutPage` +- `LayoutState` +- `LayoutUpdatePayload` +- `LinkPopoverContext` +- `LinkPopoverResolution` +- `LinkPopoverResolver` +- `ListDefinitionsPayload` +- `Measure` +- `Modules` +- `NavigableAddress` +- `OpenOptions` +- `PageMargins` +- `PageSize` +- `PageStyles` +- `PaginationPayload` +- `PaintSnapshot` +- `PartChangedEvent` +- `PartId` +- `PartSectionId` +- `PasswordPromptAttemptResult` +- `PasswordPromptConfig` +- `PasswordPromptContext` +- `PasswordPromptHandle` +- `PasswordPromptRenderContext` +- `PasswordPromptResolution` +- `PermissionParams` +- `PositionHit` +- `PresenceOptions` +- `PresentationEditorOptions` +- `ProofingCapabilities` +- `ProofingCheckRequest` +- `ProofingCheckResult` +- `ProofingConfig` +- `ProofingError` +- `ProofingIssue` +- `ProofingIssueKind` +- `ProofingProvider` +- `ProofingSegment` +- `ProofingSegmentMetadata` +- `ProofingStatus` +- `ProseMirrorJSON` +- `ProtectionChangeSource` +- `RangeRect` +- `RemoteCursorState` +- `RemoteCursorsRenderPayload` +- `RemoteUserInfo` +- `ResolveRangeOutput` +- `ResolvedFindReplaceTexts` +- `ResolvedPasswordPromptTexts` +- `SaveOptions` +- `Schema` +- `ScrollIntoViewInput` +- `ScrollIntoViewOutput` +- `SearchMatch` +- `SectionMetadata` +- `SelectionApi` +- `SelectionCommandContext` +- `SelectionCurrentInput` +- `SelectionHandle` +- `SelectionInfo` +- `StoryLocator` +- `SuperDocLayoutEngineOptions` +- `SuperDocTelemetryConfig` +- `SurfaceComponentProps` +- `SurfaceFloatingPlacement` +- `SurfaceHandle` +- `SurfaceMode` +- `SurfaceOutcome` +- `SurfaceRequest` +- `SurfaceResolution` +- `SurfaceResolver` +- `SurfacesModuleConfig` +- `TelemetryEvent` +- `TextAddress` +- `TextSegment` +- `TextTarget` +- `TrackChangesModuleConfig` +- `TrackedChangeAddress` +- `TrackedChangesMode` +- `TrackedChangesOverrides` +- `Transaction` +- `UnsupportedContentItem` +- `UpgradeToCollaborationOptions` +- `User` +- `ViewLayout` +- `ViewOptions` +- `ViewingVisibilityConfig` +- `VirtualizationOptions` + +## Evidence table + +| Name | dts | dcts | esm | cjs | fixtures | jsdoc | docs | examples | demos | boundaries | +|---|---|---|---|---|---|---|---|---|---|---| +| `AIWriter` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 4 | | +| `AnnotatorHelpers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | +| `AwarenessState` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `BinaryData` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `BlankDOCX` | โœ“ | โœ“ | โœ“ | โœ“ | 0 | | 0 | 0 | 1 | | +| `BlockNavigationAddress` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `BlocksListResult` | โœ“ | โœ“ | | | 2 | โœ“ | 1 | 0 | 1 | โœ“ | +| `BookmarkAddress` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 1 | | +| `BookmarkInfo` | โœ“ | โœ“ | | | 2 | โœ“ | 1 | 0 | 1 | โœ“ | +| `BoundingRect` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `CanObject` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `ChainableCommandObject` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `ChainedCommand` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `CollaborationConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `CollaborationProvider` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `Command` | โœ“ | โœ“ | | | 3 | โœ“ | 78 | 0 | 8 | โœ“ | +| `CommandProps` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `Comment` | โœ“ | โœ“ | | | 3 | โœ“ | 28 | 3 | 45 | | +| `CommentAddress` | โœ“ | โœ“ | | | 1 | โœ“ | 4 | 0 | 3 | | +| `CommentConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `CommentElement` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `CommentLocationsPayload` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `CommentsPayload` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `CommentsPluginKey` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 1 | โœ“ | +| `CommentsType` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `Config` | โœ“ | โœ“ | | | 5 | โœ“ | 2 | 1 | 2 | โœ“ | +| `ContextMenu` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 7 | 0 | 31 | | +| `ContextMenuConfig` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `ContextMenuContext` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `ContextMenuItem` | โœ“ | โœ“ | | | 2 | โœ“ | 4 | 0 | 5 | | +| `ContextMenuSection` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `CoreCommandMap` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | โœ“ | +| `DOCX` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 133 | 18 | 58 | โœ“ | +| `DirectSurfaceRequest` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `DocRange` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `DocumentApi` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 4 | 4 | โœ“ | +| `DocumentMode` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 10 | 3 | | +| `DocumentProtectionState` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 1 | | +| `DocxFileEntry` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `DocxZipper` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 1 | โœ“ | +| `Editor` | โœ“ | โœ“ | โœ“ | โœ“ | 4 | | 194 | 19 | 67 | โœ“ | +| `EditorCommands` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `EditorEventMap` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorExtension` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorLifecycleState` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorOptions` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 2 | | +| `EditorState` | โœ“ | โœ“ | | | 4 | โœ“ | 7 | 0 | 1 | โœ“ | +| `EditorSurface` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorTransactionEvent` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorUpdateEvent` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `EditorView` | โœ“ | โœ“ | | | 4 | โœ“ | 2 | 0 | 0 | โœ“ | +| `EntityAddress` | โœ“ | โœ“ | | | 2 | โœ“ | 276 | 0 | 8 | | +| `ExportDocxParams` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `ExportFormat` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `ExportOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ExportParams` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `ExportType` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `ExtensionCommandMap` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | โœ“ | +| `Extensions` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 14 | 6 | 3 | โœ“ | +| `ExternalPopoverRenderContext` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `ExternalSurfaceRenderContext` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `FieldValue` | โœ“ | โœ“ | | | 1 | โœ“ | 7 | 0 | 0 | | +| `FindReplaceConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `FindReplaceContext` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `FindReplaceHandle` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `FindReplaceRenderContext` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `FindReplaceResolution` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `FlowBlock` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | โœ“ | +| `FlowMode` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `FontConfig` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `FontsResolvedPayload` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `HTML` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 85 | 9 | 204 | | +| `ImageDeselectedEvent` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ImageSelectedEvent` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `IntentSurfaceRequest` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `Layout` | โœ“ | โœ“ | | | 3 | โœ“ | 9 | 0 | 22 | โœ“ | +| `LayoutEngineOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutError` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutFragment` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutMetrics` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutMode` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutPage` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutState` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LayoutUpdatePayload` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `LinkPopoverContext` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `LinkPopoverResolution` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `LinkPopoverResolver` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `ListDefinitionsPayload` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `Measure` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 1 | | +| `Modules` | โœ“ | โœ“ | | | 1 | โœ“ | 4 | 0 | 0 | | +| `NavigableAddress` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `OpenOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 1 | 0 | 0 | | +| `PDF` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 35 | 0 | 1 | โœ“ | +| `PageMargins` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PageSize` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PageStyles` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PaginationPayload` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PaintSnapshot` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PartChangedEvent` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PartId` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PartSectionId` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PasswordPromptAttemptResult` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PasswordPromptConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PasswordPromptContext` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `PasswordPromptHandle` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PasswordPromptRenderContext` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `PasswordPromptResolution` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `PermissionParams` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `PositionHit` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PresenceOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `PresentationEditor` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 40 | โœ“ | +| `PresentationEditorOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingCapabilities` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingCheckRequest` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingCheckResult` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingConfig` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingError` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingIssue` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingIssueKind` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingProvider` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingSegment` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProofingSegmentMetadata` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `ProofingStatus` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ProseMirrorJSON` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `ProtectionChangeSource` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `RangeRect` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `RemoteCursorState` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `RemoteCursorsRenderPayload` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `RemoteUserInfo` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `ResolveRangeOutput` | โœ“ | โœ“ | | | 3 | โœ“ | 1 | 0 | 1 | | +| `ResolvedFindReplaceTexts` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `ResolvedPasswordPromptTexts` | โœ“ | โœ“ | | | 1 | โœ“ | 1 | 0 | 0 | | +| `SaveOptions` | โœ“ | โœ“ | | | 4 | โœ“ | 1 | 0 | 0 | | +| `Schema` | โœ“ | โœ“ | | | 4 | โœ“ | 5 | 0 | 4 | โœ“ | +| `ScrollIntoViewInput` | โœ“ | โœ“ | | | 2 | โœ“ | 1 | 0 | 0 | | +| `ScrollIntoViewOutput` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `SearchMatch` | โœ“ | โœ“ | | | 2 | โœ“ | 3 | 0 | 0 | | +| `SectionHelpers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | +| `SectionMetadata` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `SelectionApi` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `SelectionCommandContext` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `SelectionCurrentInput` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `SelectionHandle` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `SelectionInfo` | โœ“ | โœ“ | | | 2 | โœ“ | 6 | 0 | 1 | | +| `SlashMenu` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | +| `StoryLocator` | โœ“ | โœ“ | | | 1 | โœ“ | 116 | 0 | 3 | | +| `SuperConverter` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 3 | โœ“ | +| `SuperDoc` | โœ“ | โœ“ | โœ“ | โœ“ | 7 | | 1001 | 159 | 243 | โœ“ | +| `SuperDocLayoutEngineOptions` | โœ“ | โœ“ | | | 2 | โœ“ | 0 | 0 | 0 | | +| `SuperDocTelemetryConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SuperEditor` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 16 | 0 | 5 | | +| `SuperInput` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 2 | | +| `SuperToolbar` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 4 | โœ“ | +| `SurfaceComponentProps` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceFloatingPlacement` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceHandle` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `SurfaceMode` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceOutcome` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceRequest` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceResolution` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfaceResolver` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `SurfacesModuleConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `TelemetryEvent` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `TextAddress` | โœ“ | โœ“ | | | 2 | โœ“ | 404 | 0 | 7 | | +| `TextSegment` | โœ“ | โœ“ | | | 2 | โœ“ | 8 | 0 | 4 | | +| `TextTarget` | โœ“ | โœ“ | | | 2 | โœ“ | 41 | 0 | 8 | | +| `Toolbar` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 35 | 7 | 18 | | +| `TrackChangesBasePluginKey` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 1 | โœ“ | +| `TrackChangesModuleConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `TrackedChangeAddress` | โœ“ | โœ“ | | | 1 | โœ“ | 13 | 0 | 3 | | +| `TrackedChangesMode` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `TrackedChangesOverrides` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `Transaction` | โœ“ | โœ“ | | | 3 | โœ“ | 5 | 0 | 0 | โœ“ | +| `UnsupportedContentItem` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `UpgradeToCollaborationOptions` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `User` | โœ“ | โœ“ | | | 3 | โœ“ | 49 | 6 | 30 | | +| `ViewLayout` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `ViewOptions` | โœ“ | โœ“ | | | 1 | โœ“ | 2 | 0 | 0 | | +| `ViewingVisibilityConfig` | โœ“ | โœ“ | | | 1 | โœ“ | 0 | 0 | 0 | | +| `VirtualizationOptions` | โœ“ | โœ“ | | | 3 | โœ“ | 0 | 0 | 0 | | +| `assertNodeType` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 2 | 0 | 1 | โœ“ | +| `buildTheme` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 4 | 0 | 1 | | +| `compareVersions` | โœ“ | โœ“ | โœ“ | โœ“ | 0 | | 0 | 0 | 1 | | +| `createTheme` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 21 | 8 | 1 | | +| `createZip` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 1 | โœ“ | +| `defineMark` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 3 | 0 | 1 | โœ“ | +| `defineNode` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 4 | 0 | 1 | โœ“ | +| `fieldAnnotationHelpers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 2 | 0 | 3 | | +| `getActiveFormatting` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 2 | | +| `getAllowedImageDimensions` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 1 | | +| `getFileObject` | โœ“ | โœ“ | โœ“ | โœ“ | 0 | | 0 | 0 | 7 | | +| `getMarksFromSelection` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 0 | 0 | 2 | | +| `getRichTextExtensions` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 1 | 0 | 1 | โœ“ | +| `getSchemaIntrospection` | โœ“ | โœ“ | โœ“ | โœ“ | 0 | | 3 | 0 | 1 | | +| `getStarterExtensions` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 8 | 2 | 5 | โœ“ | +| `isMarkType` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 2 | 0 | 1 | โœ“ | +| `isNodeType` | โœ“ | โœ“ | โœ“ | โœ“ | 2 | | 2 | 0 | 1 | โœ“ | +| `registeredHandlers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | +| `superEditorHelpers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | +| `trackChangesHelpers` | โœ“ | โœ“ | โœ“ | โœ“ | 1 | | 0 | 0 | 1 | | diff --git a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt index dd2de09b8a..e7526544a7 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt +++ b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt @@ -200,4 +200,5 @@ resolveSelectionTarget resolveTrackedChangeInStory seedEditorStateToYDoc shallowEqual +syncCommentEntitiesFromCollaboration trackChangesHelpers diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts index bb08998126..5d33d76fdb 100644 --- a/tests/consumer-typecheck/src/all-public-types.ts +++ b/tests/consumer-typecheck/src/all-public-types.ts @@ -6,13 +6,22 @@ * `const _real_X: AssertNotAny = true` lines fail to compile if X * has collapsed. A missing export shows up as TS2305 on the import. * - * THIS FILE IS GENERATED from the JSDoc @typedef block in - * packages/superdoc/src/index.js. Edit the typedef block (or run - * node tests/consumer-typecheck/check-public-types.mjs --write - * from the repo root, or `npm run check:types:write` from inside - * tests/consumer-typecheck) and commit both. SD-2860's check script enforces - * that the two stay in sync; a missing assertion fails CI with a message - * pointing at this script. + * SD-3213a (post root facade flip): this file is no longer auto-generated + * from `packages/superdoc/src/index.js`'s typedef block โ€” that file is no + * longer the canonical source of truth for the root contract after the + * SD-3212 PR C root types flip. The canonical root surface is now + * `packages/superdoc/src/public/index.ts`, locked by + * `tests/consumer-typecheck/snapshots/superdoc-root-exports.json` and + * classified at `tests/consumer-typecheck/snapshots/superdoc-root-classification.json`. + * + * When a new TYPE-ONLY root export lands (inDts true, inEsm/inCjs false + * in the classification), add a corresponding + * `import { X } from 'superdoc';` + `const _real_X: AssertNotAny = true;` + * line below. The `check-all-public-types-fixture.mjs` gate derives the + * expected assertion set from the classification artifact and fails CI + * if any type-only export is missing here, so you cannot silently land a + * new root type without any-collapse coverage. The SD-2842 matrix + * scenarios then exercise this file to catch the actual any-collapses. */ import type { BinaryData, diff --git a/tests/consumer-typecheck/src/customer-scenario.ts b/tests/consumer-typecheck/src/customer-scenario.ts index 178a499167..79de5d76d6 100644 --- a/tests/consumer-typecheck/src/customer-scenario.ts +++ b/tests/consumer-typecheck/src/customer-scenario.ts @@ -347,7 +347,13 @@ async function testExportDocx(editor: Editor) { // Specific overloads โ†’ narrowed return types const xml: string = await editor.exportDocx({ exportXmlOnly: true }); - const json: string = await editor.exportDocx({ exportJsonOnly: true }); + // SD-3248: exportJsonOnly returns the xml-js intermediate tree (recursive + // `name` / `attributes` / `elements` shape), NOT a JSON string. The + // previous `string` annotation was a type lie that did not match runtime + // (callers were already walking `.elements[0]` directly in tests). + const json: { name?: string; attributes?: Record; elements?: unknown[] } = await editor.exportDocx({ + exportJsonOnly: true, + }); const docs: Record = await editor.exportDocx({ getUpdatedDocs: true }); } diff --git a/tests/consumer-typecheck/src/editor-pm-generics.ts b/tests/consumer-typecheck/src/editor-pm-generics.ts new file mode 100644 index 0000000000..36fab60924 --- /dev/null +++ b/tests/consumer-typecheck/src/editor-pm-generics.ts @@ -0,0 +1,109 @@ +/** + * Consumer typecheck: ProseMirror generic defaults on the public + * Editor / Node surface (SD-3213 sub 2 drain). + * + * Before this change, `Editor.schema`, `Editor.registerPlugin`, and + * `NodeConfig.addPmPlugins` all exposed bare `Schema` / `Plugin` / + * `Plugin[]` without explicit type args. TypeScript filled those in + * as `Schema` and `Plugin`, leaking `any` through the + * SD-3213 supported-root audit (28 findings). + * + * This fixture pins three contracts: + * 1. `editor.schema` is typed `Schema` so node/mark + * names are constrained but not collapsed to `any`. Consumer + * schemas with literal-name unions stay assignable. + * 2. `editor.registerPlugin(plugin)` preserves the + * incoming plugin's state type into the optional `handlePlugins` + * callback. The existing plugin list parameter stays + * `Plugin[]` because the runtime list is heterogeneous. + * 3. `NodeConfig.addPmPlugins` accepts `Plugin[]` instead + * of the bare `Plugin[]` (= `Plugin[]`) that leaked `any`. + * + * Also asserts that `EditorState.create({ schema, plugins })` from + * raw `prosemirror-state` continues to typecheck against the editor + * surface, since SuperDoc's narrowed signatures must not break the + * underlying ProseMirror contract (`EditorStateConfig.plugins` is + * `readonly Plugin[]`). + */ + +import type { Editor } from 'superdoc'; +import { EditorState, Plugin, PluginKey } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; + +declare const editor: Editor; + +// --- 1. editor.schema: Schema ------------------------------ + +// Field type is `Schema`. `nodes` / `marks` are indexed +// by `string`, which is what node lookups across the editor surface use. +const nodeMap = editor.schema.nodes; +const markMap = editor.schema.marks; +void nodeMap; +void markMap; + +// A consumer schema declared with literal-name unions remains assignable +// to the wider `Schema` field. Variance: literal-string +// types extend `string`, so a `Schema<'paragraph' | 'text', 'em'>` is a +// subtype of `Schema` at this position. +declare const literalSchema: Schema<'paragraph' | 'text', 'em'>; +const _schemaAssignableToField: typeof editor.schema = literalSchema; +void _schemaAssignableToField; + +// --- 2. registerPlugin preserves state via generic ------------------------- + +interface MyPluginState { + count: number; +} + +const myPluginKey = new PluginKey('my'); +const myPlugin: Plugin = new Plugin({ + key: myPluginKey, + state: { + init: () => ({ count: 0 }), + apply: (_tr, value) => value, + }, +}); + +// Without the optional callback: PluginState is inferred from the +// argument, so the typed plugin flows through without any cast or +// widening to `any`. +editor.registerPlugin(myPlugin); + +// With the optional callback: the callback's `plugin` argument keeps +// `MyPluginState`. The list parameter is `Plugin[]` because +// the editor's plugin list is heterogeneous. Returning the typed +// plugin in that list requires a one-position cast at the boundary +// because ProseMirror's `Plugin` is invariant in T (verified: +// `Plugin` is not assignable to `Plugin` +// because `EditorProps

` uses P in both produces and consumes +// positions). The cast is honest about PM's variance; there is no +// `any` introduced here. +editor.registerPlugin(myPlugin, (plugin, plugins) => { + // `plugin` keeps `MyPluginState` in the callback. + const _typedPlugin: Plugin = plugin; + void _typedPlugin; + return [...plugins, plugin as Plugin]; +}); + +// --- 3. addPmPlugins on NodeConfig accepts Plugin[] -------------- +// +// `NodeConfig.addPmPlugins?: MaybeGetter[]>`. The +// list element type is `Plugin`. Consumers with typed +// plugins cast at the list-construction boundary (same Plugin +// invariance constraint as the registerPlugin callback above). +type AddPmPluginsReturn = Plugin[]; +const pmPluginList: AddPmPluginsReturn = [myPlugin as Plugin]; +void pmPluginList; + +// --- 4. EditorState.create({ schema, plugins }) round-trip ----------------- +// +// SuperDoc's narrowed types must not break the underlying ProseMirror +// contract. `EditorStateConfig.plugins` is `readonly Plugin[]` (= raw +// `Plugin[]` at the PM boundary), so a typed plugin or our +// `Plugin[]` both flow through to `EditorState.create` +// without friction (any[] absorbs them). +const roundTripState = EditorState.create({ + schema: editor.schema, + plugins: [myPlugin], +}); +void roundTripState; 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..7d01d9375d --- /dev/null +++ b/tests/consumer-typecheck/src/editor-surfaces-not-any.ts @@ -0,0 +1,107 @@ +/** + * 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. SD-3248 tightened the +// JSON branch to the recursive `ConvertedXmlPart` shape (was +// `Record`) so the runtime tree (`{ name, attributes, +// elements }`) is named honestly; the `Editor.exportDocx` overload was +// corrected in the same wave. Structural assertion pins both branches +// without requiring `ConvertedXmlPart` itself to be a named public +// export. +type AwaitedExport = Awaited>; +type StringBranch = Extract; +type TreeBranch = Exclude; +const _hasStringBranch: Equal = true; +const _treeBranchIsTree: TreeBranch extends { + name?: string; + attributes?: Record; + elements?: unknown[]; +} + ? true + : false = true; +void _hasStringBranch; +void _treeBranchIsTree; diff --git a/tests/consumer-typecheck/src/event-emitter-contract.ts b/tests/consumer-typecheck/src/event-emitter-contract.ts new file mode 100644 index 0000000000..4c74a2551e --- /dev/null +++ b/tests/consumer-typecheck/src/event-emitter-contract.ts @@ -0,0 +1,58 @@ +/** + * Consumer typecheck: EventEmitter typed payloads vs unknown fallback + * (SD-3213 EventEmitter drain). + * + * The local `EventEmitter` (used by `Editor`, `PresentationEditor`, + * `Whiteboard`, etc.) had `DefaultEventMap = Record`, + * which leaked `any[]` through every untyped event subscription on the + * public surface. SD-3213 tightened the default to + * `Record`. + * + * This fixture pins both sides of the intended contract: + * + * - **Known typed events keep precise payloads.** `EditorEventMap` + * declares `commentsLoaded: [{ editor: Editor; comments: Comment[]; ... }]`. + * After the drain, `editor.on('commentsLoaded', cb)` still types `cb`'s + * payload as the precise tuple; `comments` is still `Comment[]`. + * - **Untyped events fall through to `unknown[]`, not `any[]`.** Calling + * `editor.on('arbitraryEventName', cb)` (not in `EditorEventMap`) + * now gives `cb: (...args: unknown[]) => void`. Accessing `.foo` on + * an `unknown` argument is a TS error, proven by `@ts-expect-error`. + * + * If a future PR widens the default back to `any[]` (or narrows a typed + * event), one of these assertions stops erroring (TS2578) and tsc fails. + */ + +import type { Comment, Editor } from 'superdoc'; + +declare const editor: Editor; + +// --- Negative assertion: untyped event payloads are unknown, not any -------- + +editor.on('arbitraryEventName', (...args) => { + // `args` is `unknown[]` after SD-3213. Accessing a property without + // narrowing must error. If args slips back to `any[]`, the + // directive on the line below becomes unused and tsc fails (TS2578). + // @ts-expect-error SD-3213: arbitrary event args are unknown[], not any[] + args[0].toUpperCase(); + + // Narrowing first works fine (proves the type is `unknown`, not `never`). + const first = args[0]; + if (typeof first === 'string') { + void first.toUpperCase(); + } +}); + +// --- Positive assertion: known typed event payload retains shape ----------- + +editor.on('commentsLoaded', (payload) => { + // `EditorEventMap.commentsLoaded` is typed as + // `[{ editor: Editor; replacedFile?: boolean; comments: Comment[] }]`. + // The drain must not regress this. + const editorRef: Editor = payload.editor; + const comments: Comment[] = payload.comments; + const replacedFile: boolean | undefined = payload.replacedFile; + void editorRef; + void comments; + void replacedFile; +}); diff --git a/tests/consumer-typecheck/src/extensions-helpers.ts b/tests/consumer-typecheck/src/extensions-helpers.ts new file mode 100644 index 0000000000..f9a794f344 --- /dev/null +++ b/tests/consumer-typecheck/src/extensions-helpers.ts @@ -0,0 +1,60 @@ +/** + * Consumer typecheck: getStarterExtensions / getRichTextExtensions + * return EditorExtension[] (SD-3213 drain). + * + * Before this change, the hand-written + * `packages/super-editor/src/editors/v1/extensions/index.d.ts` + * declared both functions as `(...args: any[]): any[]`, doubly wrong: + * - Runtime takes zero args (verified across all internal and + * documented call sites). + * - Return is a concrete `EditorExtension[]`, not `any[]`. + * + * After this change, consumers passing the result into + * `new Editor({ extensions: getStarterExtensions() })` get a typed + * array, and any attempt to pass arguments is a TS error. + */ + +import { getStarterExtensions, getRichTextExtensions } from 'superdoc/super-editor'; +import type { EditorExtension } from 'superdoc/super-editor'; + +// --- Return type is EditorExtension[], not any[] -------------------------- + +const starter: EditorExtension[] = getStarterExtensions(); +const rich: EditorExtension[] = getRichTextExtensions(); +void starter; +void rich; + +// Strict type-equality assertion. A function silently returning `any[]` +// would still be assignable to `EditorExtension[]`, masking a regression. +// `Equal` fails the test if the return type drifts back to `any[]` (or +// any other shape). +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertEqual = Equal extends true ? true : never; + +const _starterReturnIsExact: AssertEqual, EditorExtension[]> = true; +const _richReturnIsExact: AssertEqual, EditorExtension[]> = true; +void _starterReturnIsExact; +void _richReturnIsExact; + +// --- Element type is not any ---------------------------------------------- + +// If the element type were `any`, this `Equal` check +// would compile to `true`. Asserting it's NOT `any` proves the +// EditorExtension union is actually applied. +const first = starter[0]; +if (first) { + const _firstIsNotAny: Equal = false; + void _firstIsNotAny; +} + +// --- Arguments are rejected ----------------------------------------------- + +// Runtime takes zero arguments. Passing anything must be a TS error; +// if a future PR widens the signature back to `(...args: any[])`, +// the directive becomes unused and tsc fails (TS2578). + +// @ts-expect-error SD-3213: getStarterExtensions takes no arguments. +getStarterExtensions('docx'); + +// @ts-expect-error SD-3213: getRichTextExtensions takes no arguments. +getRichTextExtensions({ some: 'option' }); diff --git a/tests/consumer-typecheck/src/field-annotation-helpers-typed.ts b/tests/consumer-typecheck/src/field-annotation-helpers-typed.ts new file mode 100644 index 0000000000..f0d115c962 --- /dev/null +++ b/tests/consumer-typecheck/src/field-annotation-helpers-typed.ts @@ -0,0 +1,132 @@ +/** + * Consumer typecheck: `fieldAnnotationHelpers` namespace returns + * typed `{ node, pos }` entries, not `any[]` (SD-2980 PR A). + * + * Before this change, every helper in `fieldAnnotationHelpers` resolved + * to `(...args: any[]) => any[]` in the published `.d.ts` because the + * source files were JavaScript without `@param` / `@returns` JSDoc. + * 34 audit findings tracked the leak. After PR A, JSDoc is in place; + * this fixture pins each helper's return so a regression breaks the + * typecheck matrix, not just the inventory count. + * + * Element shape pinned: `{ node, pos }` where `pos` is a number and + * `node` exposes the ProseMirror Node API (`type.name`, `attrs`, + * `nodeSize`). One helper adds a `rect: DOMRect` field. + */ + +import { Editor, fieldAnnotationHelpers } from 'superdoc/super-editor'; +import type { EditorState, Transaction } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import type { Node as PmNode } from 'prosemirror-model'; + +declare const state: EditorState; +declare const view: EditorView; +declare const doc: PmNode; +declare const tr: Transaction; +declare const editor: Editor; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +// --- Each helper's return is NOT any[] ----------------------------------- + +type GetAllReturn = ReturnType; +const _getAllNotAnyArr: Equal = false; +void _getAllNotAnyArr; + +type FindReturn = ReturnType; +const _findNotAnyArr: Equal = false; +void _findNotAnyArr; + +type FindByIdReturn = ReturnType; +const _findByIdNotAnyArr: Equal = false; +void _findByIdNotAnyArr; + +type FindBetweenReturn = ReturnType; +const _findBetweenNotAnyArr: Equal = false; +void _findBetweenNotAnyArr; + +type WithRectReturn = ReturnType; +const _withRectNotAnyArr: Equal = false; +void _withRectNotAnyArr; + +// --- Element shape: { node: PmNode; pos: number } ------------------------ + +// Structural pin. If the element ever degrades to `any` or loses the +// `node`/`pos` fields, this assignment breaks. +function consumeEntry(entry: GetAllReturn[number]): void { + const n: PmNode = entry.node; + const p: number = entry.pos; + void n; + void p; + // `node` must expose ProseMirror Node members, not just be `any`. + const _typeName: string = entry.node.type.name; + const _nodeSize: number = entry.node.nodeSize; + void _typeName; + void _nodeSize; +} +void consumeEntry; + +// --- getAllFieldAnnotationsWithRect carries a real DOMRect --------------- + +function consumeWithRect(entry: WithRectReturn[number]): void { + const _rect: DOMRect = entry.rect; + // DOMRect has typed top/left/width/height. Asserting `number` proves + // the rect didn't degrade to `any`. + const _top: number = entry.rect.top; + const _width: number = entry.rect.width; + void _rect; + void _top; + void _width; +} +void consumeWithRect; + +// --- Predicate parameter is typed Node, not any -------------------------- + +// If `predicate` were `(...args: any[]) => any`, this `@ts-expect-error` +// would become unused and tsc would fail with TS2578. +fieldAnnotationHelpers.findFieldAnnotations((node) => { + // Real PmNode members must work: + return node.type.name === 'fieldAnnotation'; +}, state); + +// @ts-expect-error SD-2980: predicate receives a Node, not a string. +fieldAnnotationHelpers.findFieldAnnotations((s: string) => s.length > 0, state); + +// --- findFirstFieldAnnotationByFieldId returns nullable entry, not any -- + +type FirstReturn = ReturnType; +const _firstNotAny: Equal = false; +void _firstNotAny; +// Must include `null` in the union (no match case). +const _firstHandlesNull: null extends FirstReturn ? true : false = true; +void _firstHandlesNull; + +// --- Argument types are real, not any ------------------------------------ + +// state: EditorState (not any). If it were any, this `@ts-expect-error` +// would not fire and tsc would catch the unused directive. +fieldAnnotationHelpers.getAllFieldAnnotations(state); +// @ts-expect-error SD-2980: getAllFieldAnnotations needs an EditorState. +fieldAnnotationHelpers.getAllFieldAnnotations('not a state'); + +// view: EditorView (not any). +fieldAnnotationHelpers.getAllFieldAnnotationsWithRect(view, state); +// @ts-expect-error SD-2980: getAllFieldAnnotationsWithRect needs an EditorView first arg. +fieldAnnotationHelpers.getAllFieldAnnotationsWithRect('not a view', state); + +// tr: Transaction (not any). +fieldAnnotationHelpers.findRemovedFieldAnnotations(tr); +// @ts-expect-error SD-2980: findRemovedFieldAnnotations needs a Transaction. +fieldAnnotationHelpers.findRemovedFieldAnnotations('not a tr'); + +// doc: PmNode (not any). +fieldAnnotationHelpers.findFieldAnnotationsBetween(0, 10, doc); +// @ts-expect-error SD-2980: findFieldAnnotationsBetween needs a Node as doc. +fieldAnnotationHelpers.findFieldAnnotationsBetween(0, 10, 'not a doc'); + +// editor: Editor (not any). +fieldAnnotationHelpers.getHeaderFooterAnnotations(editor); +// @ts-expect-error SD-2980: getHeaderFooterAnnotations needs an Editor. +fieldAnnotationHelpers.getHeaderFooterAnnotations('not an editor'); + +fieldAnnotationHelpers.trackFieldAnnotationsDeletion(editor, tr); diff --git a/tests/consumer-typecheck/src/imports-converter.ts b/tests/consumer-typecheck/src/imports-converter.ts index 9cbfbd0d8c..a32000bd78 100644 --- a/tests/consumer-typecheck/src/imports-converter.ts +++ b/tests/consumer-typecheck/src/imports-converter.ts @@ -4,6 +4,11 @@ * Pre-SD-2953 this subpath was exported at runtime but had no `.d.ts`, * so a strict consumer importing from it hit TS7016. SD-2953 added a * `types` field pointing at the existing SuperConverter declaration. + * + * SD-3213c tightened the static method signatures and added a typed + * constructor. The assertions below lock that subset of the contract + * (note: `SuperConverter` instance access still flows through the + * retained `[key: string]: any` catchall โ€” see SD-3235 for that work). */ import { SuperConverter, hasBodyNumberingReferences } from 'superdoc/converter'; @@ -15,11 +20,34 @@ type Assert = T; type _ConverterReal = Assert>; type _HasBodyNumberingReferencesReal = Assert>; -// Static methods documented in the .d.ts must resolve. -const _v: string | null = SuperConverter.extractDocumentGuid(''); +// Typed statics resolve and return the declared shapes (SD-3213c contract). +const _version: string | null = SuperConverter.getStoredSuperdocVersion([ + { name: 'docProps/custom.xml', content: '' }, +]); +const _updatedVersion: string | null = SuperConverter.setStoredSuperdocVersion({}, '1.2.3'); +const _v: string | null = SuperConverter.extractDocumentGuid([{ name: 'word/settings.xml', content: '' }]); const _hasNumberingReferences: boolean = hasBodyNumberingReferences({ elements: [{ name: 'w:numPr' }], }); +// Each typed static must NOT be `any`. +type _GetVersionReal = Assert>; +type _SetVersionReal = Assert>; +type _ExtractGuidReal = Assert>; + +// Tightened param shape: passing a raw string to a method that expects +// `readonly { name; content }[]` must error. +// @ts-expect-error extractDocumentGuid expects DOCX file entries, not raw XML. +SuperConverter.extractDocumentGuid(''); + +// Constructor accepts the documented init keys the impl reads, including +// `xml` and `json` which earlier drafts of the typed constructor missed. +const _converterFromXml = new SuperConverter({ xml: '', debug: true }); +const _converterFromJson = new SuperConverter({ json: { elements: [] }, debug: true }); +void _converterFromXml; +void _converterFromJson; + +void _version; +void _updatedVersion; void _v; void _hasNumberingReferences; diff --git a/tests/consumer-typecheck/src/imports-docx-zipper.ts b/tests/consumer-typecheck/src/imports-docx-zipper.ts index e692d26df1..89cf12c9c7 100644 --- a/tests/consumer-typecheck/src/imports-docx-zipper.ts +++ b/tests/consumer-typecheck/src/imports-docx-zipper.ts @@ -4,6 +4,11 @@ * Pre-SD-2953 this subpath was exported at runtime but had no `.d.ts`, * so a strict consumer importing from it hit TS7016. SD-2953 added a * `types` field pointing at the existing DocxZipper declaration. + * + * SD-3213c drained the `[key: string]: any` catchall from + * `DocxZipper.d.ts` and added typed instance properties + methods. The + * assertions below lock that contract so a future PR cannot silently + * reintroduce `any` while still passing the broad matrix. */ import DocxZipper from 'superdoc/docx-zipper'; @@ -14,7 +19,40 @@ type Assert = T; // DocxZipper must NOT be `any`. type _ZipperReal = Assert>; -// Constructable as a class. -const _zipper = new DocxZipper(); +// Constructable as a class with the typed params object. +const _zipper = new DocxZipper({ debug: true }); + +// Instance properties must NOT be `any` (SD-3213c contract). +type _MediaReal = Assert>; +type _MediaFilesReal = Assert>; +type _FontsReal = Assert>; +type _DecryptedReal = Assert>; + +// Instance methods must NOT be `any` (SD-3213c contract). +type _GetDocxDataReal = Assert>; +type _UpdateContentTypesReal = Assert>; +type _UpdateZipReal = Assert>; + +// Declared properties and methods must be callable with the public shapes. +const _media: Record = _zipper.media; +const _mediaFiles: Record = _zipper.mediaFiles; +const _fonts: Record = _zipper.fonts; +const _decryptedFileData: Uint8Array | null = _zipper.decryptedFileData; +const _docxData: Promise<{ name: string; content: string }[]> = _zipper.getDocxData(new Uint8Array(), true, { + password: 'secret', +}); +const _contentTypes: Promise = _zipper.updateContentTypes({}, {}, false); +const _zip: Promise> = _zipper.updateZip({}); + +// The `[key: string]: any` catchall is gone; arbitrary access must error. +// @ts-expect-error DocxZipper no longer exposes arbitrary `any` members. +_zipper.notARealMember; void _zipper; +void _media; +void _mediaFiles; +void _fonts; +void _decryptedFileData; +void _docxData; +void _contentTypes; +void _zip; diff --git a/tests/consumer-typecheck/src/imports-headless-toolbar.ts b/tests/consumer-typecheck/src/imports-headless-toolbar.ts index 95ea5e74d4..1a7ae50b26 100644 --- a/tests/consumer-typecheck/src/imports-headless-toolbar.ts +++ b/tests/consumer-typecheck/src/imports-headless-toolbar.ts @@ -48,3 +48,42 @@ const linkValue: string | null | undefined = snapshot.commands['link']?.value; // Verify ToolbarExecuteFn type const execFn: ToolbarExecuteFn = (id, payload?) => true; + +// SD-3213: ToolbarTarget.commands is the documented escape hatch for +// direct command access when execute() doesn't cover the use case +// (see headless-toolbar/README.md). The index-signature value is +// `(...args: unknown[]) => unknown`, not `(...args: any[]) => any`, +// so consumers narrow before reading return values. +declare const ctx: ToolbarContext; +const targetCommands = ctx.target.commands; +const someCommand = targetCommands['someCommand']; +if (someCommand) { + // Return is `unknown`, not `any`. Reading a property without + // narrowing must error; if a future PR widens back to `any`, the + // directive becomes unused and tsc fails (TS2578). + const result = someCommand('arg1', 42); + // @ts-expect-error SD-3213: target.commands[id] returns unknown, not any. + result.foo; + // Narrowing works as expected. + const _untyped: unknown = result; + void _untyped; +} +void targetCommands; +void execFn; + +// SD-3213: a consumer constructing a custom ToolbarTarget (e.g. for +// tests or a non-Editor command source) can still satisfy the +// tightened signature by typing their commands with the same +// `(...args: unknown[]) => unknown` shape. This pins the most common +// custom-stub construction so a future re-widening or narrowing +// would surface here. +const customTarget: ToolbarTarget = { + commands: { + arbitrary: (...args) => { + // `args` is `unknown[]`; reading args[0].foo would error + // without narrowing. + return args.length; + }, + }, +}; +void customTarget; diff --git a/tests/consumer-typecheck/src/node-render-dom.ts b/tests/consumer-typecheck/src/node-render-dom.ts new file mode 100644 index 0000000000..90d3412881 --- /dev/null +++ b/tests/consumer-typecheck/src/node-render-dom.ts @@ -0,0 +1,95 @@ +/** + * Consumer typecheck: NodeConfig.renderDOM no longer leaks `any` + * through ProseMirror's DOMOutputSpec tuple branch (SD-3213 drain). + * + * Before this change, `renderDOM?: MaybeGetter` pulled + * in PM's upstream `readonly [string, ...any[]]` tuple shape, leaking + * `any` into the SD-3213 supported-root audit (6 findings, all reach + * paths to this one field). + * + * After this change, the field is typed with + * `MaybeGetter`, a local public alias that + * mirrors PM's shape but uses an `unknown`-free recursive tuple + * interface (`SuperDocDOMOutputSpecTuple`). + * + * This fixture exercises the four shape branches through `defineNode`, + * the documented consumer entry point: + * - plain string ("em" โ€” a self-closing inline tag name) + * - plain tuple with attrs and content hole (["span", { class }, 0]) + * - nested tuples (["div", ["span", { class }, "text"]]) + * - { dom, contentDOM? } form (used for custom DOM mount nodes) + * + * Plus one negative assertion: a number where an attrs object or + * child spec is expected must error. + */ + +import { defineNode } from 'superdoc/super-editor'; + +// Plain string: tagName-only render. Compiles because `string` is a +// branch of `SuperDocDOMOutputSpec`. +defineNode({ + name: 'plainStringSpec', + renderDOM: () => 'em', +}); + +// Plain tuple with optional attrs + content hole (0). The most common +// shape for custom node renderers. Attrs accept string/number/boolean +// /null/undefined for setAttribute coercion compatibility. +defineNode({ + name: 'tupleWithAttrsAndHole', + renderDOM: () => ['span', { class: 'my-class', 'data-count': 3, hidden: true }, 0], +}); + +// Nested tuples for child renderers. The recursive +// SuperDocDOMOutputSpecTuple interface defers self-reference so TS +// doesn't trip on the direct recursion that a plain type alias hits. +defineNode({ + name: 'nestedTuple', + renderDOM: () => ['div', { class: 'wrap' }, ['span', { class: 'inner' }, 'text']], +}); + +// `{ dom, contentDOM? }` form. Used when a custom node needs to +// distinguish the outer mount node from the editable content node. +defineNode({ + name: 'domContentDomShape', + renderDOM: () => { + const dom = document.createElement('div'); + const contentDOM = document.createElement('span'); + dom.appendChild(contentDOM); + return { dom, contentDOM }; + }, +}); + +// --- Negative assertions ------------------------------------------------- + +// renderDOM is function-only at the type level, matching what the +// runtime in `Schema.js:99` actually supports (`renderDOM({ node, +// htmlAttributes })`). A direct-value form like `renderDOM: ['br']` +// would throw `TypeError: renderDOM is not a function` at runtime, +// so the public type rejects it. If a future PR re-widens the field +// to `MaybeGetter` (or back to PM's tuple- +// containing union), the directive becomes unused and tsc fails +// (TS2578). +defineNode({ + name: 'directValueRejected', + // @ts-expect-error SD-3213: renderDOM is function-only; runtime invokes it as a callable. + renderDOM: ['br'], +}); + +// A number can't be an attrs object or child spec. If a future PR +// widens the tuple element type back to `any` or `unknown`, this +// `@ts-expect-error` becomes unused and tsc fails (TS2578). +defineNode({ + name: 'badTupleElement', + // @ts-expect-error SD-3213: tuple elements must be attrs object, nested spec, or 0; not a bare number. + renderDOM: () => ['div', 42, 'no'], +}); + +// First tuple element must be a string (tagName). Passing a number +// must error; if a future PR re-widens the tuple, this directive +// becomes unused and tsc fails (TS2578). +defineNode({ + name: 'badTagName', + // @ts-expect-error SD-3213: tuple[0] (tagName) must be string. + renderDOM: () => [42, { class: 'no' }, 0], +}); diff --git a/tests/consumer-typecheck/src/provider-collaboration-provider.ts b/tests/consumer-typecheck/src/provider-collaboration-provider.ts index 14c7e4fde2..03f7e51903 100644 --- a/tests/consumer-typecheck/src/provider-collaboration-provider.ts +++ b/tests/consumer-typecheck/src/provider-collaboration-provider.ts @@ -55,3 +55,33 @@ const docWithMinimalProvider: DocumentEntry = { // Reference all bindings so `tsc --noEmit` doesn't strip them. void [_sdProviderTypeIsExact, _docProviderTypeIsExact, minimalProvider, docWithMinimalProvider]; + +// SD-3213: `CollaborationProvider.on/off` use `(event: string, handler: +// (...args: unknown[]) => void)` instead of `(event: any, handler: +// (...args: any[]) => void)`. Mirrors the existing pattern on +// `Awareness` and matches the internal `ProviderEventHandler` cast in +// `helpers/collaboration-provider-sync.ts`. This drained 32 supported- +// root any-leak findings on EditorConfig.d.ts in a single source edit. +declare const providerToInspect: CollaborationProvider; +providerToInspect.on?.('synced', (...args) => { + // `args` is `unknown[]`, not `any[]`. Reading a property on an + // untyped element must error; if a future PR widens this back to + // `any[]`, the directive becomes unused and tsc fails (TS2578). + // @ts-expect-error SD-3213: provider on() args are unknown[], not any[]. + args[0].foo; + + // Narrowing the unknown element works as expected; `as unknown` + // assignment also compiles. Both prove `args[0]` is `unknown`. + const first = args[0]; + if (typeof first === 'object' && first !== null && 'message' in first) { + void (first as { message: string }).message; + } + const _untyped: unknown = args[0]; + void _untyped; +}); + +// `event` is `string`, not `any`. Passing a non-string must error; +// if a future PR widens back to `any`, the directive becomes unused +// and tsc fails (TS2578). +// @ts-expect-error SD-3213: provider on() event is string, not any. +providerToInspect.on?.(123, () => {}); diff --git a/tests/consumer-typecheck/src/superdoc-events.ts b/tests/consumer-typecheck/src/superdoc-events.ts new file mode 100644 index 0000000000..3acf0d9c4f --- /dev/null +++ b/tests/consumer-typecheck/src/superdoc-events.ts @@ -0,0 +1,294 @@ +/** + * Consumer typecheck: SuperDoc typed event map + * (SD-3213 follow-up to the Whiteboard event map #3422). + * + * Before this change, `SuperDoc` extended `eventemitter3` with no event + * map, so every `superdoc.on(name, cb)` gave consumers `(...args: any[]) + * => void`. The documentation at `apps/docs/editor/superdoc/events.mdx` + * advertises ~15 events with specific payload shapes, but none of those + * shapes was typed for consumers. + * + * The event map is **closed**: unknown event names (e.g. typos like + * `'reayd'`) are TS errors. This is a TS-only tightening: the runtime + * `eventemitter3` still accepts any string, so only consumers relying + * on dynamic event names see new errors. Verified internal SuperDoc + * code emits/subscribes only to the enumerated events. + * + * `exception` is typed as a union of three payload shapes the runtime + * currently emits today; consumers narrow with `'stage' in payload` etc. + * Normalizing the emit sites is tracked as a separate follow-up. + * + * `whiteboard:change` reuses the `WhiteboardData` typedef from + * the stacked SD-3213 Whiteboard PR (#3422); this fixture verifies that + * the integration works end-to-end through `superdoc.on('whiteboard:change', ...)`. + */ + +import type { SuperDoc, Editor, User, AwarenessState, Comment, DocumentMode } from 'superdoc'; + +declare const superdoc: SuperDoc; + +// --- Lifecycle events ------------------------------------------------------ + +superdoc.on('ready', ({ superdoc: instance }) => { + const ref: SuperDoc = instance; + void ref; +}); + +superdoc.on('editorBeforeCreate', ({ editor }) => { + const ref: Editor = editor; + void ref; +}); + +superdoc.on('editorCreate', ({ editor }) => { + const ref: Editor = editor; + void ref; +}); + +superdoc.on('editorDestroy', () => { + // No payload. +}); + +superdoc.on('pdf:document-ready', () => { + // No payload. +}); + +// --- UI events ------------------------------------------------------------- + +superdoc.on('sidebar-toggle', (isOpened) => { + const flag: boolean = isOpened; + void flag; +}); + +superdoc.on('zoomChange', ({ zoom }) => { + const value: number = zoom; + void value; +}); + +superdoc.on('formatting-marks-change', ({ showFormattingMarks, superdoc: instance }) => { + const flag: boolean = showFormattingMarks; + const ref: SuperDoc = instance; + void flag; + void ref; +}); + +superdoc.on('document-mode-change', ({ documentMode }) => { + // `documentMode` narrows to the documented closed union. + const mode: DocumentMode = documentMode; + void mode; + // Negative: any other string must error. + // @ts-expect-error SD-3213: DocumentMode is closed; only editing/viewing/suggesting. + const bad: DocumentMode = 'reviewing'; + void bad; +}); + +// --- Content events -------------------------------------------------------- + +superdoc.on('editor-update', (payload) => { + // Envelope is required surface/headerId/sectionType; editor and + // sourceEditor optional (effectiveEditor may be undefined). + const surface: string = payload.surface; + const headerId: string | null = payload.headerId; + const sectionType: string | null = payload.sectionType; + const editor: Editor | undefined = payload.editor; + const source: Editor | undefined = payload.sourceEditor; + void surface; + void headerId; + void sectionType; + void editor; + void source; +}); + +superdoc.on('content-error', ({ error, editor }) => { + // `error` is `unknown` (the runtime emit accepts arbitrary errors). + // Consumers must narrow before reading. + void error; + const ref: Editor = editor; + void ref; +}); + +superdoc.on('fonts-resolved', (payload) => { + // Payload reuses the existing public `FontsResolvedPayload`. + const documentFonts: string[] = payload.documentFonts; + const unsupportedFonts: string[] = payload.unsupportedFonts; + void documentFonts; + void unsupportedFonts; +}); + +superdoc.on('pagination-update', ({ totalPages, superdoc: instance }) => { + const count: number = totalPages; + const ref: SuperDoc = instance; + void count; + void ref; +}); + +superdoc.on('list-definitions-change', (payload) => { + // Reuses existing public `ListDefinitionsPayload`. Inner fields are + // typed as `unknown` (intentional: deep shape is not part of the + // public contract). + const change: unknown = payload.change; + void change; +}); + +// --- Comments events ------------------------------------------------------- + +superdoc.on('comments-update', (event) => { + // `type` is the closed `CommentEvent` union; switching on it lets + // consumers narrow per-case. + const type: string = event.type; + void type; + if (event.comment) { + const comment: Comment = event.comment; + const id: string = comment.commentId; + void id; + } + if (event.changes) { + for (const change of event.changes) { + const key: string = change.key; + const commentId: string = change.commentId; + void key; + void commentId; + } + } +}); + +// --- Collaboration events -------------------------------------------------- + +superdoc.on('collaboration-ready', ({ editor }) => { + const ref: Editor = editor; + void ref; +}); + +superdoc.on('awareness-update', ({ states, added, removed, superdoc: instance }) => { + for (const state of states) { + const s: AwarenessState = state; + void s; + } + for (const id of added) { + const n: number = id; + void n; + } + for (const id of removed) { + const n: number = id; + void n; + } + const ref: SuperDoc = instance; + void ref; +}); + +superdoc.on('locked', ({ isLocked, lockedBy }) => { + const locked: boolean = isLocked; + // `lockedBy` is optional; when present it can be User or null + // (runtime initializes as `config.lockedBy || null`). + const user: User | null | undefined = lockedBy; + void locked; + void user; +}); + +// --- Whiteboard events (re-uses WhiteboardData from #3422) ----------------- + +superdoc.on('whiteboard:init', ({ whiteboard }) => { + // Whiteboard is the public class; just exercising the binding. + void whiteboard; +}); + +superdoc.on('whiteboard:ready', ({ whiteboard }) => { + void whiteboard; +}); + +superdoc.on('whiteboard:change', (data) => { + // `WhiteboardData` from the stacked #3422: output shape with required + // fields, so no optional chaining needed. + const pages = data.pages; + void pages; + const meta = data.meta; + void meta; + const version: 1 = data.version; + void version; +}); + +superdoc.on('whiteboard:enabled', (enabled) => { + const flag: boolean = enabled; + void flag; +}); + +superdoc.on('whiteboard:tool', (tool) => { + const name: string = tool; + void name; +}); + +// --- Exception (union of three current runtime shapes) --------------------- + +superdoc.on('exception', (payload) => { + // `error` is always present on every union member. + void payload.error; + + // The store-init shape is uniquely identified by `stage`. The other + // two shapes (restore vs editor lifecycle) overlap structurally and + // can't be cleanly discriminated without a tag, so consumers narrow + // with `'stage' in payload` for the store case and use `'code' in + // payload` or `'document' in payload` for the others. + if ('stage' in payload && payload.stage === 'document-init') { + const stage: 'document-init' = payload.stage; + void stage; + void payload.document; + } +}); + +// --- Closed-map negative assertion ----------------------------------------- + +// Unknown event names must be a TS error. If a future PR widens the map +// with an index signature (open fallback), this directive becomes unused +// and tsc fails (TS2578). +// @ts-expect-error SD-3213: SuperDocEventMap is closed; unknown events are not allowed. +superdoc.on('reayd', () => {}); + +// --- Host contract: real SuperDoc + narrow + broad stubs all compile ------- + +// `createHeadlessToolbar({ superdoc })` and `createSuperDocUI({ superdoc })` +// accept a real SuperDoc instance even after the closed event-map +// tightening. The host shapes split their `on`/`off` event-name unions +// to exactly what each controller subscribes to: +// `HeadlessToolbarSuperdocHostEvent` (4 events) for the toolbar host, +// `SuperDocUIHostEvent` (3 events) for the UI controller. SuperDoc's +// closed `SuperDocEventMap`-typed `on` satisfies both. +import { createHeadlessToolbar } from 'superdoc/headless-toolbar'; +import { createSuperDocUI } from 'superdoc/ui'; +void createHeadlessToolbar({ superdoc }); +void createSuperDocUI({ superdoc }); + +// Custom UI host stub typed precisely to the 3 events the UI +// controller subscribes to must satisfy `SuperDocLike`. Pinning this +// so a future widening of `SuperDocUIHostEvent` (e.g. re-adding +// `formatting-marks-change`) doesn't silently regress this stub +// shape: such a change would fail this assertion under strict +// (property-syntax) variance, and would still be a precision loss +// even under TS method bivariance. +declare const customUIHost: { + on?(event: 'editorCreate' | 'document-mode-change' | 'zoomChange', handler: (...args: unknown[]) => void): unknown; + off?(event: 'editorCreate' | 'document-mode-change' | 'zoomChange', handler: (...args: unknown[]) => void): unknown; +}; +void createSuperDocUI({ superdoc: customUIHost }); + +// Custom toolbar host stub typed precisely to the 4 events the +// toolbar subscribes to must satisfy `HeadlessToolbarSuperdocHost`. +declare const customToolbarHost: { + on?: ( + event: 'editorCreate' | 'document-mode-change' | 'formatting-marks-change' | 'zoomChange', + listener: (...args: any[]) => void, + ) => void; + off?: ( + event: 'editorCreate' | 'document-mode-change' | 'formatting-marks-change' | 'zoomChange', + listener: (...args: any[]) => void, + ) => void; +}; +void createHeadlessToolbar({ superdoc: customToolbarHost }); + +// Broad string-based custom stubs remain assignable to both host +// contracts: a function that accepts any string can be called with +// the specific event names the host will pass. +declare const broadHost: { + on?: (event: string, listener: (...args: unknown[]) => void) => void; + off?: (event: string, listener: (...args: unknown[]) => void) => void; +}; +void createHeadlessToolbar({ superdoc: broadHost }); +void createSuperDocUI({ superdoc: broadHost }); diff --git a/tests/consumer-typecheck/src/superdoc-stores-private.ts b/tests/consumer-typecheck/src/superdoc-stores-private.ts new file mode 100644 index 0000000000..e89aad5989 --- /dev/null +++ b/tests/consumer-typecheck/src/superdoc-stores-private.ts @@ -0,0 +1,110 @@ +/** + * Consumer typecheck: SuperDoc's internal Pinia stores must not appear + * on the public TypeScript surface (SD-3213f). + * + * `superdoc.superdocStore`, `superdoc.commentsStore`, and + * `superdoc.highContrastModeStore` are internal Vue/Pinia runtime + * references. Earlier versions of the published `SuperDoc.d.ts` exposed + * them as public properties, which leaked the full Pinia store type + * graph into the public surface and collapsed consumer IntelliSense to + * `any` at every depth that reached through them. This fixture pins the + * SD-3213f decision: those three fields are `@private` on `SuperDoc.js`, + * so a strict consumer importing `SuperDoc` from `superdoc` must not be + * able to access them. + * + * This is a TypeScript-surface hide, not runtime privacy. The fields + * still exist on the runtime instance and internal package callers + * keep working. Consumers can no longer reach into them via `.d.ts`. + * + * Positive checks below also pin that the documented host-accepting + * factories `createHeadlessToolbar({ superdoc })` and + * `createSuperDocUI({ superdoc })` continue to compile with a + * `SuperDoc` instance after the hide. They compile because SD-3213f + * also refactored `HeadlessToolbarSuperdocHost`: the raw + * `superdocStore?` field was removed and replaced with two narrow + * optional methods (`getPresentationEditorForDocument`, `getComment`) + * that SuperDoc now implements directly. The internal + * `resolveToolbarSources` keeps a `superdocStore?` legacy fallback for + * custom host stubs that pre-date the narrow methods; cleanup of the + * remaining `as never` casts in `create-super-doc-ui.ts` is tracked + * separately as SD-3213g. + */ + +import { SuperDoc } from 'superdoc'; +import { createHeadlessToolbar } from 'superdoc/headless-toolbar'; +import { createSuperDocUI } from 'superdoc/ui'; + +declare const superdoc: SuperDoc; + +// --- Negative assertions --------------------------------------------------- +// Internal Pinia stores must not appear on the public SuperDoc surface. +// If a future change reintroduces them as public properties, the +// `@ts-expect-error` directive stops erroring (TS2578) and tsc fails. + +// @ts-expect-error superdocStore is internal (SD-3213f); not part of the +// public TypeScript surface. +void superdoc.superdocStore; + +// @ts-expect-error commentsStore is internal (SD-3213f); not part of the +// public TypeScript surface. +void superdoc.commentsStore; + +// @ts-expect-error highContrastModeStore is internal (SD-3213f); not part +// of the public TypeScript surface. +void superdoc.highContrastModeStore; + +// @ts-expect-error commentsList is the internal SuperComments mount +// handle (SD-3213); not part of the public TypeScript surface. +void superdoc.commentsList; + +// @ts-expect-error app is the internal Vue app handle (SD-3213); +// not part of the public TypeScript surface. The documented public +// surface is `superdoc.toolbar` (the SuperToolbar wrapper), not +// `superdoc.app`. +void superdoc.app; + +// @ts-expect-error toolbar.toolbar is the internal Vue +// ComponentPublicInstance mounted by SuperToolbar (SD-3213); the +// documented public surface is `superdoc.toolbar` itself. The +// nested `.toolbar` field is internal mount state. +void superdoc.toolbar.toolbar; + +// Positive: `superdoc.toolbar` (the SuperToolbar class instance) +// remains accessible: it is the documented public surface +// (`apps/docs/editor/built-in-ui/toolbar.mdx` shows multiple +// `const toolbar = superdoc.toolbar` examples). +void superdoc.toolbar; + +// --- Positive assertions --------------------------------------------------- +// Documented factories accepting a SuperDoc instance must continue to +// compile after the hide. These compile because SuperDoc now exposes the +// narrow host methods (`getPresentationEditorForDocument`, `getComment`) +// that replaced `HeadlessToolbarSuperdocHost.superdocStore?` in SD-3213f. + +const _toolbarController = createHeadlessToolbar({ superdoc }); +const _superDocUI = createSuperDocUI({ superdoc }); + +void _toolbarController; +void _superDocUI; + +// --- Backward-compat: legacy inline host with `superdocStore` ---------------- +// Pre-SD-3213f typed custom host stubs passed an inline object literal +// that included a typed `superdocStore.documents[]`. The SD-3213f host +// type is a union with a deprecated legacy branch so those stubs keep +// compiling without `any` casts. Without the union branch, TS would +// reject this object literal under excess-property checks at the +// `createHeadlessToolbar` call site. +const _legacyToolbarController = createHeadlessToolbar({ + superdoc: { + activeEditor: null, + superdocStore: { + documents: [ + { + getEditor: () => null, + getPresentationEditor: () => null, + }, + ], + }, + }, +}); +void _legacyToolbarController; diff --git a/tests/consumer-typecheck/src/track-changes-helpers-typed.ts b/tests/consumer-typecheck/src/track-changes-helpers-typed.ts new file mode 100644 index 0000000000..a1b7682d00 --- /dev/null +++ b/tests/consumer-typecheck/src/track-changes-helpers-typed.ts @@ -0,0 +1,244 @@ +/** + * Consumer typecheck: every exported helper in `trackChangesHelpers` + * returns a real shape, not `any` / `any[]`. + * + * Initially landed for SD-2980 PR B (markSnapshotHelpers + + * documentHelpers, 39 findings); extended for PR C with the remaining + * 4 helpers (getLiveInlineMarksInRange, findTrackedMarkBetween, + * trackedTransaction, getTrackChanges, 14 findings) โ€” together these + * drain the entire tier-3-helpers bucket for trackChanges. The fixture + * pins the visible return / parameter shapes so a regression breaks + * the typecheck matrix, not just the inventory count. + * + * Coverage: + * - markSnapshotHelpers (PR B): createMarkSnapshot, getTypeName, + * isTrackFormatNoOp, attrsExactlyMatch, markSnapshotMatchesStepMark, + * hasMatchingMark, upsertMarkSnapshotByType, findMarkInRangeBySnapshot + * - documentHelpers (PR B): findMarkPosition, flatten, findChildren, + * findInlineNodes (the 3-arg track-changes variant, distinct from + * `@core/helpers/findChildren`) + * - PR C helpers: getLiveInlineMarksInRange, findTrackedMarkBetween, + * trackedTransaction, getTrackChanges + */ + +import { trackChangesHelpers } from 'superdoc/super-editor'; +import type { Node as PmNode, Mark as PmMark } from 'prosemirror-model'; +import type { EditorState, Transaction } from 'prosemirror-state'; + +declare const doc: PmNode; +declare const mark: PmMark; +declare const liveMarks: PmMark[]; +declare const state: EditorState; +declare const tr: Transaction; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +// --- MarkSnapshot output shape ------------------------------------------- + +const snap = trackChangesHelpers.createMarkSnapshot('bold'); +const _snapType: string = snap.type; +const _snapAttrs: Record = snap.attrs; +void _snapType; +void _snapAttrs; + +// `createMarkSnapshot` return is NOT any. +const _snapNotAny: Equal = false; +void _snapNotAny; + +// --- getTypeName returns string | undefined ------------------------------ + +const name = trackChangesHelpers.getTypeName(snap); +const _nameIsStringOrUndefined: Equal = true; +void _nameIsStringOrUndefined; +// Accepts a live PM Mark too. +trackChangesHelpers.getTypeName(mark); + +// --- isTrackFormatNoOp / attrsExactlyMatch return boolean ---------------- + +const _noopReturn: boolean = trackChangesHelpers.isTrackFormatNoOp([snap], [snap]); +const _attrsMatchReturn: boolean = trackChangesHelpers.attrsExactlyMatch({ a: 1 }, { a: 1 }); +void _noopReturn; +void _attrsMatchReturn; + +// --- hasMatchingMark + markSnapshotMatchesStepMark return boolean ------- + +const _hasMatch: boolean = trackChangesHelpers.hasMatchingMark(liveMarks, snap); +const _stepMatch: boolean = trackChangesHelpers.markSnapshotMatchesStepMark(snap, snap, true); +void _hasMatch; +void _stepMatch; + +// --- upsertMarkSnapshotByType returns MarkSnapshot[] (output strict) ----- + +const upserted = trackChangesHelpers.upsertMarkSnapshotByType([snap], snap); +const _upsertedNotAnyArr: Equal = false; +void _upsertedNotAnyArr; +// Element members are typed. +if (upserted[0]) { + const _t: string = upserted[0].type; + const _a: Record = upserted[0].attrs; + void _t; + void _a; +} + +// --- findMarkInRangeBySnapshot returns PmMark | null -------------------- + +const liveMark = trackChangesHelpers.findMarkInRangeBySnapshot({ + doc, + from: 0, + to: 10, + snapshot: snap, +}); +const _liveMarkNotAny: Equal = false; +void _liveMarkNotAny; +// `null` must be in the union. +const _nullableLive: null extends typeof liveMark ? true : false = true; +void _nullableLive; +if (liveMark) { + // PM Mark exposes `.type.name` (MarkType.name), not just `any`. + const _typeName: string = liveMark.type.name; + void _typeName; +} + +// --- documentHelpers: findMarkPosition returns nullable range ----------- + +const range = trackChangesHelpers.documentHelpers.findMarkPosition(doc, 5, 'link'); +const _rangeNotAny: Equal = false; +void _rangeNotAny; +if (range) { + const _from: number = range.from; + const _to: number = range.to; + const _attrs: Record = range.attrs; + void _from; + void _to; + void _attrs; +} + +// --- documentHelpers: flatten / findChildren / findInlineNodes ---------- + +type FlattenReturn = ReturnType; +type FindChildrenReturn = ReturnType; +type FindInlineReturn = ReturnType; + +const _flattenNotAnyArr: Equal = false; +const _findChildrenNotAnyArr: Equal = false; +const _findInlineNotAnyArr: Equal = false; +void _flattenNotAnyArr; +void _findChildrenNotAnyArr; +void _findInlineNotAnyArr; + +// Element shape: { node: PmNode; pos: number } +function consumeEntry(entry: FlattenReturn[number]): void { + const _n: PmNode = entry.node; + const _p: number = entry.pos; + const _typeName: string = entry.node.type.name; + void _n; + void _p; + void _typeName; +} +void consumeEntry; + +// 3-arg findChildren is distinct from the simpler core variant. +trackChangesHelpers.documentHelpers.findChildren(doc, (node) => node.isInline, true); +trackChangesHelpers.documentHelpers.findChildren(doc, (node) => node.isInline); + +// Predicate receives PmNode, not any. +trackChangesHelpers.documentHelpers.findChildren(doc, (node) => { + return node.type.name === 'paragraph'; +}); + +// @ts-expect-error SD-2980 PR B: predicate must accept a Node, not a string. +trackChangesHelpers.documentHelpers.findChildren(doc, (s: string) => s.length > 0); + +// @ts-expect-error SD-2980 PR B: findMarkPosition needs a string mark name. +trackChangesHelpers.documentHelpers.findMarkPosition(doc, 5, 42); + +// ========================================================================= +// PR C: remaining trackChanges helpers +// ========================================================================= + +// --- getLiveInlineMarksInRange returns PmMark[] ------------------------- + +const liveInline = trackChangesHelpers.getLiveInlineMarksInRange({ doc, from: 0, to: 10 }); +const _liveInlineNotAnyArr: Equal = false; +void _liveInlineNotAnyArr; +if (liveInline[0]) { + // PM Mark exposes `.type.name`, not just `any`. + const _typeName: string = liveInline[0].type.name; + void _typeName; +} + +// @ts-expect-error SD-2980 PR C: getLiveInlineMarksInRange needs { doc, from, to }. +trackChangesHelpers.getLiveInlineMarksInRange({ doc, from: 0 }); + +// --- findTrackedMarkBetween returns TrackedMarkRange | null -------------- + +const tracked = trackChangesHelpers.findTrackedMarkBetween({ + tr, + from: 0, + to: 10, + markName: 'trackInsert', +}); +const _trackedNotAny: Equal = false; +void _trackedNotAny; +// `null` must be in the union (no match case). +const _trackedHandlesNull: null extends typeof tracked ? true : false = true; +void _trackedHandlesNull; +if (tracked) { + const _from: number = tracked.from; + const _to: number = tracked.to; + const _markType: string = tracked.mark.type.name; + void _from; + void _to; + void _markType; +} + +// Optional `attrs` and `offset` are accepted. +trackChangesHelpers.findTrackedMarkBetween({ + tr, + from: 0, + to: 10, + markName: 'trackInsert', + attrs: { id: 'abc' }, + offset: 0, +}); + +// @ts-expect-error SD-2980 PR C: markName is required and must be a string. +trackChangesHelpers.findTrackedMarkBetween({ tr, from: 0, to: 10 }); + +// --- trackedTransaction returns Transaction ------------------------------ + +declare const user: { name: string; email: string }; +const resultTr = trackChangesHelpers.trackedTransaction({ tr, state, user }); +const _resultTrNotAny: Equal = false; +void _resultTrNotAny; +// The return is a PM Transaction: it exposes `.docChanged`, `.steps`, etc. +const _docChanged: boolean = resultTr.docChanged; +const _stepsLen: number = resultTr.steps.length; +void _docChanged; +void _stepsLen; + +// Optional `replacements` accepts the literal union. +trackChangesHelpers.trackedTransaction({ tr, state, user, replacements: 'independent' }); + +// @ts-expect-error SD-2980 PR C: replacements must be 'paired' | 'independent'. +trackChangesHelpers.trackedTransaction({ tr, state, user, replacements: 'bogus' }); + +// --- getTrackChanges returns TrackedMarkRange[] ------------------------- + +const changes = trackChangesHelpers.getTrackChanges(state); +const _changesNotAnyArr: Equal = false; +void _changesNotAnyArr; +if (changes[0]) { + const _markType: string = changes[0].mark.type.name; + const _from: number = changes[0].from; + const _to: number = changes[0].to; + void _markType; + void _from; + void _to; +} + +// Tolerates missing state per the JSDoc contract. +trackChangesHelpers.getTrackChanges(null); +trackChangesHelpers.getTrackChanges(undefined); +// Filter by id. +trackChangesHelpers.getTrackChanges(state, 'change-1'); diff --git a/tests/consumer-typecheck/src/whiteboard-data-shape.ts b/tests/consumer-typecheck/src/whiteboard-data-shape.ts new file mode 100644 index 0000000000..132c1f6928 --- /dev/null +++ b/tests/consumer-typecheck/src/whiteboard-data-shape.ts @@ -0,0 +1,125 @@ +/** + * Consumer typecheck: Whiteboard data shape contracts (SD-3213 follow-up). + * + * Pre-PR, the public types for Whiteboard / WhiteboardPage carried + * three any-leak categories that all hurt consumers reading whiteboard + * data: + * + * 1. `WhiteboardPage.toJSON()` declared `{ strokes: any[]; text: any[]; + * stickers: any[] }` โ€” the type said `stickers`, but the runtime + * always returned `images`. Consumers reading the typed return + * would have written `result.stickers` and silently gotten + * `undefined`. + * 2. `WhiteboardPage.{strokes, text, images}` were typed as the + * *authored* shapes (e.g. `{ points, x, y }`), but the runtime + * stores *normalized* shapes (`{ pointsN, xN, yN }`). Consumers + * reaching for `page.strokes[0].points` would have hit + * `pointsN` at runtime with no IntelliSense. + * 3. `Whiteboard.register` / `Whiteboard.getType` accepted / + * returned `any[]`, giving consumers no IntelliSense for the + * stable `id` field the runtime relies on. + * + * This fixture pins all three: the serialized field is `images` (not + * `stickers`); stored items use normalized field names; registry items + * expose `id` typed. + */ + +import type { SuperDoc } from 'superdoc'; + +declare const superdoc: SuperDoc; + +const whiteboard = superdoc.whiteboard; +if (whiteboard) { + // --- 1. getWhiteboardData() returns the normalized page shape with + // `images`, not `stickers` ----------------------------------------- + const data = whiteboard.getWhiteboardData(); + const pages = data.pages ?? {}; + const firstKey = Object.keys(pages)[0]; + if (firstKey !== undefined) { + const page = pages[firstKey]!; + + // `images` exists and is typed + const images = page.images; + void images; + + // `stickers` was a stale JSDoc artifact; it must NOT appear on the + // public shape. If it slips back, this @ts-expect-error stops + // erroring and tsc fails (TS2578). + // @ts-expect-error SD-3213: toJSON() return uses `images`, not `stickers`. + void page.stickers; + } + + // --- 2. Stored items use normalized field names (not authored) ----------- + const allPages = whiteboard.getPages(); + const firstPage = allPages[0]; + if (firstPage) { + const firstStroke = firstPage.strokes[0]; + if (firstStroke) { + // Normalized field: `pointsN`, not `points`. + const pointsN: number[][] = firstStroke.pointsN; + const widthN: number | undefined = firstStroke.widthN; + void pointsN; + void widthN; + + // `points` was the authored input field, normalized away on store. + // @ts-expect-error SD-3213: stored strokes have `pointsN`, not `points`. + void firstStroke.points; + } + + const firstText = firstPage.text[0]; + if (firstText) { + // Stored text uses normalized coordinates and font size. + // widthN includes null because #toNormalizedText falls back to + // null when the input width is non-finite. + const xN: number = firstText.xN; + const yN: number = firstText.yN; + const content: string = firstText.content; + const fontSizeN: number | undefined = firstText.fontSizeN; + const textWidthN: number | null | undefined = firstText.widthN; + void xN; + void yN; + void content; + void fontSizeN; + void textWidthN; + } + + const firstImage = firstPage.images[0]; + if (firstImage) { + // Stored images use normalized coordinates/sizes plus the source URL. + // widthN / heightN include null (#toNormalizedImage fallback). + // stickerId is `string | number | null` because addImage() forwards + // item.id (which is `string | number`) when type === 'sticker'. + const xN: number = firstImage.xN; + const imageWidthN: number | null | undefined = firstImage.widthN; + const imageHeightN: number | null | undefined = firstImage.heightN; + const src: string = firstImage.src; + const stickerId: string | number | null | undefined = firstImage.stickerId; + void xN; + void imageWidthN; + void imageHeightN; + void src; + void stickerId; + } + } + + // --- 3. Registry items expose `id` typed, arbitrary fields are unknown -- + whiteboard.register('stickers', [{ id: 's1', label: 'sticker' }]); + const items = whiteboard.getType('stickers'); + if (items) { + const first = items[0]; + if (first) { + // `id` is typed. + const id: string | number | undefined = first.id; + void id; + + // Arbitrary fields are `unknown`, not `any`. Reading them without + // narrowing must error. Bracket access is required under + // `noPropertyAccessFromIndexSignature` (which the strict + // "all public surface" scenario enables); same intent either way. + const label = first['label']; + // @ts-expect-error SD-3213: arbitrary registry fields are unknown, not any. + label.toUpperCase(); + void label; + } + } +} diff --git a/tests/consumer-typecheck/src/whiteboard-events.ts b/tests/consumer-typecheck/src/whiteboard-events.ts new file mode 100644 index 0000000000..408f540679 --- /dev/null +++ b/tests/consumer-typecheck/src/whiteboard-events.ts @@ -0,0 +1,113 @@ +/** + * Consumer typecheck: Whiteboard event-map typed payloads + * (SD-3213 follow-up to the EventEmitter `unknown[]` drain). + * + * Before this change, `Whiteboard` extended `EventEmitter` with no + * event map, so every listener received `unknown[]` (post-#3420) or + * `any[]` (pre-#3420) regardless of which event was named. + * `whiteboard.on('change', cb)` gave consumers no payload type, even + * though the runtime always emits `WhiteboardData`. + * + * The same change splits `WhiteboardData` (output: `getWhiteboardData()` + * return + `change` event payload, all fields required) from + * `WhiteboardDataInput` (input to `setWhiteboardData(json)`, all + * fields optional). Consumers reading the change payload can write + * `data.meta.pageSizes` without optional chaining, and existing + * `setWhiteboardData({ pages: {...} })` callers keep working through + * the looser input type. + * + * This fixture pins all five typed event payloads, the closed + * event-map shape (unknown event names are TS errors), and the + * `WhiteboardData` field accessibility. + * + * Registry shape narrowing via `register` / `getType` is a + * separate design decision (caller-asserted shape vs runtime-verified) + * tracked as a follow-up; this PR keeps the existing + * `WhiteboardRegistryItem` contract. + */ + +import type { SuperDoc } from 'superdoc'; + +declare const superdoc: SuperDoc; + +const whiteboard = superdoc.whiteboard; +if (whiteboard) { + // --- Each typed event payload narrows precisely -------------------------- + + whiteboard.on('change', (data) => { + // `data.pages` is required (`Record`). + // No optional chaining or null-check needed โ€” the runtime always + // populates the field. + const pages = data.pages; + const firstKey = Object.keys(pages)[0]; + if (firstKey !== undefined) { + const page = pages[firstKey]!; + // Stored shape narrows through: `images`, not `stickers` + // (pinned separately in whiteboard-data-shape.ts, exercised + // here through the event payload entry point). + void page.images; + } + + // `data.meta` and `data.version` are required on the output + // shape (the runtime always sets both). Required on + // `WhiteboardData`, optional on `WhiteboardDataInput` โ€” that + // split is the point of this PR. Consumers can read these + // fields without optional chaining. + const pageSizes = data.meta.pageSizes; + const firstSizeKey = Object.keys(pageSizes)[0]; + if (firstSizeKey !== undefined) { + const size = pageSizes[firstSizeKey]!; + const width: number = size.width; + const height: number = size.height; + const originalWidth: number | null = size.originalWidth; + const originalHeight: number | null = size.originalHeight; + void width; + void height; + void originalWidth; + void originalHeight; + } + // `version` is the literal `1` (current schema version). If a + // future schema bump widens this to `number`, this assertion + // becomes incompatible and must be revisited alongside consumers. + const version: 1 = data.version; + void version; + }); + + whiteboard.on('setData', (pages) => { + // `pages` is `WhiteboardPage[]`. `.length` is typed, no cast needed. + const count: number = pages.length; + void count; + }); + + whiteboard.on('tool', (tool) => { + const name: string = tool; + void name; + }); + + whiteboard.on('enabled', (enabled) => { + const flag: boolean = enabled; + void flag; + }); + + whiteboard.on('opacity', (opacity) => { + const value: number = opacity; + void value; + }); + + // Unknown event names must be a TS error (closed event map, no + // DefaultEventMap fallback). If a future PR widens the map by adding + // an index signature, this directive becomes unused and tsc fails + // (TS2578). + // @ts-expect-error SD-3213: WhiteboardEventMap is closed; unknown events are not allowed. + whiteboard.on('not-a-real-event', () => {}); + + // --- WhiteboardDataInput round-trip + partial accept --------------------- + + // Round trip: the strict output of getWhiteboardData() must be + // assignable to the looser setWhiteboardData() input. + whiteboard.setWhiteboardData(whiteboard.getWhiteboardData()); + + // Partial input: callers can still pass just `{ pages }` without + // supplying `meta` or `version` (the runtime only reads `json?.pages`). + whiteboard.setWhiteboardData({ pages: {} }); +} diff --git a/tests/consumer-typecheck/typecheck-matrix.mjs b/tests/consumer-typecheck/typecheck-matrix.mjs index 0978c4d981..586a9da2b5 100644 --- a/tests/consumer-typecheck/typecheck-matrix.mjs +++ b/tests/consumer-typecheck/typecheck-matrix.mjs @@ -34,27 +34,21 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, '..', '..'); const skipPack = process.argv.includes('--skip-pack'); -const skipTypeCheck = process.argv.includes('--skip-public-types-check'); -// SD-2860: before doing any of the matrix work, fail fast if the public-type -// surface drifted from the assertion list. Otherwise a developer who added a -// new public typedef can ship past every other gate without an assertion for -// the new type. -if (!skipTypeCheck) { - console.log('Checking public-type surface against the assertion list...'); - try { - execSync('node check-public-types.mjs', { - cwd: __dirname, - stdio: 'inherit', - }); - } catch (e) { - console.error('\nPublic-type surface check failed (see message above).'); - console.error('Run `node tests/consumer-typecheck/check-public-types.mjs --write` from the repo root (or `npm run check:types:write` from inside `tests/consumer-typecheck/`) to regenerate the assertion list, then commit the result.'); - console.error('(`tests/consumer-typecheck` is intentionally outside the pnpm workspace, so `pnpm --filter` cannot reach it.)'); - process.exit(1); - } - console.log(); +// SD-3213a: fail fast if `src/all-public-types.ts` has drifted from the +// canonical type-only root contract in `superdoc-root-classification.json`. +// Replaces the retired pre-flip source-sync gate (SD-2860) that pointed at +// the legacy `packages/superdoc/src/index.js` typedef block. The fixture +// is the input to the SD-2842 any-collapse scenarios below; without this +// gate, a new type-only root export would land uncovered. +console.log('Checking all-public-types.ts fixture against the classification...'); +try { + execSync('node check-all-public-types-fixture.mjs', { cwd: __dirname, stdio: 'inherit' }); +} catch (e) { + console.error('\nPublic-types fixture check failed (see message above).'); + process.exit(1); } +console.log(); if (!skipPack) { console.log('Packing superdoc and reinstalling fixture...'); @@ -376,6 +370,56 @@ const scenarios = [ files: ['src/track-changes-helpers.ts'], mustPass: true, }, + // SD-2980 PR A: fieldAnnotationHelpers exported under `superdoc/super-editor` + // now carry typed JSDoc on every helper. The fixture pins each helper's + // return shape (`{ node, pos }`, plus DOMRect for the rect variant) and + // proves arguments are real ProseMirror types, not `any`. + { + name: 'bundler / field annotation helper typing (SD-2980)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: false, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/field-annotation-helpers-typed.ts'], + mustPass: true, + }, + { + name: 'node16 / field annotation helper typing (SD-2980)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: false, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/field-annotation-helpers-typed.ts'], + mustPass: true, + }, + // SD-2980 PR B: trackChangesHelpers core (markSnapshotHelpers + + // documentHelpers) now carry typed JSDoc. The fixture pins each + // exported helper's return shape, the MarkSnapshot output type, the + // PmMark|null return on findMarkInRangeBySnapshot, and the 3-arg + // documentHelpers.findChildren signature (distinct from the simpler + // @core/helpers/findChildren). + { + name: 'bundler / track changes helpers typing (SD-2980 PR B)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: false, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/track-changes-helpers-typed.ts'], + mustPass: true, + }, + { + name: 'node16 / track changes helpers typing (SD-2980 PR B)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: false, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/track-changes-helpers-typed.ts'], + mustPass: true, + }, // SD-2892: full public-facing surface with skipLibCheck=false. These // scenarios pack SuperDoc, install it into the consumer fixture, and compile // every public consumer assertion under the resolution modes customers use. @@ -524,6 +568,226 @@ const scenarios = [ files: ['src/internal-fields-stripped.ts'], mustPass: true, }, + // SD-3213f: SuperDoc's internal Pinia stores must not appear on the + // public TypeScript surface. The fixture pins both the negative + // assertions (stores are TS errors for consumers) and the positive + // host-factory assertions (createHeadlessToolbar / createSuperDocUI + // still accept a real SuperDoc instance after the hide). + { + name: 'bundler / superdoc stores private (SD-3213f)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + files: ['src/superdoc-stores-private.ts'], + mustPass: true, + }, + { + name: 'node16 / superdoc stores private (SD-3213f)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + files: ['src/superdoc-stores-private.ts'], + mustPass: true, + }, + // SD-3213 EventEmitter drain: pin both sides of the intended contract. + // Known typed events keep precise payloads (commentsLoaded.comments + // is Comment[]); untyped events fall through to unknown[] not any[]. + { + name: 'bundler / event-emitter contract (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + files: ['src/event-emitter-contract.ts'], + mustPass: true, + }, + { + name: 'node16 / event-emitter contract (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + files: ['src/event-emitter-contract.ts'], + mustPass: true, + }, + // SD-3213 Whiteboard data-shape typing: pin the three contracts + // tightened in this PR โ€” toJSON returns `images` not `stickers`, + // stored items use normalized field names (pointsN/xN/yN/widthN), + // and registry items expose `id` typed with `unknown` for extras. + { + name: 'bundler / whiteboard data shape (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + files: ['src/whiteboard-data-shape.ts'], + mustPass: true, + }, + { + name: 'node16 / whiteboard data shape (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + files: ['src/whiteboard-data-shape.ts'], + mustPass: true, + }, + // SD-3213 Whiteboard event map: pin typed payloads for + // whiteboard.on(name, fn) across all 5 events plus the runtime + // `meta` and `version` fields newly exposed on WhiteboardData. + // Closed event map (no DefaultEventMap fallback), so unknown event + // names are TS errors. Generic register/getType is a separate + // design decision tracked as a follow-up. + { + name: 'bundler / whiteboard events (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/whiteboard-events.ts'], + mustPass: true, + }, + { + name: 'node16 / whiteboard events (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/whiteboard-events.ts'], + mustPass: true, + }, + // SD-3213: getStarterExtensions / getRichTextExtensions return + // EditorExtension[], not any[]. Runtime takes no arguments; the + // previous hand-written .d.ts was wrong on both sides. + { + name: 'bundler / extensions helpers (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/extensions-helpers.ts'], + mustPass: true, + }, + { + name: 'node16 / extensions helpers (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + 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 + // (string, tuple with attrs+0, nested tuples, { dom, contentDOM? }) + // plus negative assertions for bad tuple elements and non-string + // tagName. + { + name: 'bundler / node renderDOM (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/node-render-dom.ts'], + mustPass: true, + }, + { + name: 'node16 / node renderDOM (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/node-render-dom.ts'], + mustPass: true, + }, + // SD-3213 sub 2: ProseMirror generic defaults on Editor + Node + // public surface. `Editor.schema` becomes `Schema`, + // `Editor.registerPlugin(plugin)` preserves the state + // type into the optional handlePlugins callback, and + // `NodeConfig.addPmPlugins` accepts `Plugin[]`. Includes + // an EditorState.create({ schema, plugins }) round-trip to prove + // the narrowed types stay compatible with raw prosemirror-state. + { + name: 'bundler / editor PM generics (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/editor-pm-generics.ts'], + mustPass: true, + }, + { + name: 'node16 / editor PM generics (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/editor-pm-generics.ts'], + mustPass: true, + }, + // SD-3213 SuperDoc event map: typed payloads for the documented + // public superdoc.on(...) events. Closed map (typos like + // superdoc.on('reayd', ...) are TS errors). Reuses existing public + // types (User, Editor, AwarenessState, Comment, DocumentMode, + // FontsResolvedPayload, ListDefinitionsPayload, WhiteboardData). + // Exception payload is a union of the three current runtime shapes; + // normalization is a follow-up. + { + name: 'bundler / superdoc events (SD-3213)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/superdoc-events.ts'], + mustPass: true, + }, + { + name: 'node16 / superdoc events (SD-3213)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + noPropertyAccessFromIndexSignature: true, + files: ['src/superdoc-events.ts'], + mustPass: true, + }, // SD-2867 phase B: SuperDoc.canPerformPermission forwards `comment` and // `trackedChange` to isAllowed() unchanged, so the public contract must // accept the wide payloads the editor's permission helper produces diff --git a/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts index 7d23a65ab9..a90c16e8b2 100644 --- a/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts +++ b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts @@ -26,9 +26,9 @@ function makeSessionId(label: string): string { } // SD-3094 / SD-3093: When a paragraph is RTL (w:bidi), the doc-api -// `format.paragraph.setAlignment` takes a *display* alignment (what the user -// sees) and must write the spec-correct *stored* w:jc value per ECMA-376 -// ยง17.3.1.13 (left = leading edge, right = trailing edge). +// `format.paragraph.setAlignment` takes a visual page alignment (what the user +// sees). Microsoft Word interprets stored w:jc through w:bidi, so visual left +// must be exported as w:jc="right" and visual right as w:jc="left". describe('document-api story: rtl paragraph alignment write', () => { const { client, outPath } = useStoryHarness('formatting/rtl-alignment-api', { preserveResults: true, @@ -77,7 +77,7 @@ describe('document-api story: rtl paragraph alignment write', () => { return savePath; } - it('setAlignment(left) on RTL paragraph exports w:jc=right (mirrored to trailing-edge stored value)', async () => { + it('setAlignment(left) on RTL paragraph exports w:jc=right (Word-compatible visual-left storage)', async () => { const sessionId = makeSessionId('rtl-align-left'); const paragraphText = 'RTL paragraph align-left case'; @@ -102,7 +102,7 @@ describe('document-api story: rtl paragraph alignment write', () => { expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(0); }); - it('setAlignment(right) on RTL paragraph exports w:jc=left (mirrored to leading-edge stored value)', async () => { + it('setAlignment(right) on RTL paragraph exports w:jc=left (Word-compatible visual-right storage)', async () => { const sessionId = makeSessionId('rtl-align-right'); const paragraphText = 'RTL paragraph align-right case'; diff --git a/tests/document-api-smoke/README.md b/tests/document-api-smoke/README.md new file mode 100644 index 0000000000..8c73ebe2a1 --- /dev/null +++ b/tests/document-api-smoke/README.md @@ -0,0 +1,16 @@ +# Document API Smoke + +This package keeps the small in-repo guardrails for the +Document API: + +- representative namespace and method presence +- a small SDK open/read/mutate/save/reopen smoke workflow + +Additional conformance coverage may exist in a separate checkout. This package +contains only the in-repo smoke suite. + +Run the in-repo smoke suite from the repo root: + +```bash +pnpm run test:document-api-smoke +``` diff --git a/tests/document-api-smoke/package.json b/tests/document-api-smoke/package.json new file mode 100644 index 0000000000..30d2acad5a --- /dev/null +++ b/tests/document-api-smoke/package.json @@ -0,0 +1,15 @@ +{ + "name": "@superdoc-testing/document-api-smoke", + "private": true, + "type": "module", + "scripts": { + "pretest": "pnpm --silent --dir ../.. run generate:all && pnpm --silent --prefix ../../apps/cli run build && pnpm --silent --prefix ../../packages/sdk/langs/node run build", + "test": "vitest run --config ./vitest.config.ts" + }, + "dependencies": { + "@superdoc-dev/sdk": "workspace:*" + }, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/tests/document-api-smoke/src/sdk-smoke.test.ts b/tests/document-api-smoke/src/sdk-smoke.test.ts new file mode 100644 index 0000000000..658761d001 --- /dev/null +++ b/tests/document-api-smoke/src/sdk-smoke.test.ts @@ -0,0 +1,155 @@ +import os from 'node:os'; +import path from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, test } from 'vitest'; +import { createSuperDocClient, type SuperDocClient, type SuperDocDocument } from '@superdoc-dev/sdk'; + +const REPO_ROOT = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))); +const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/dist/index.js'); + +function createSessionId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getBlocks(items: unknown): Array> { + if (!isRecord(items) || !Array.isArray(items.blocks)) { + throw new Error('Expected blocks.list() to return a blocks array.'); + } + return items.blocks.filter(isRecord); +} + +function findBlockNodeId(items: unknown, nodeType: string): string { + const block = getBlocks(items).find((entry) => entry.nodeType === nodeType); + if (!block || typeof block.nodeId !== 'string') { + throw new Error(`Expected to find a ${nodeType} block with a string nodeId.`); + } + return block.nodeId; +} + +describe('document api smoke', () => { + let client: SuperDocClient | null = null; + let doc: SuperDocDocument | null = null; + let tempDir: string | null = null; + + afterEach(async () => { + await doc?.close({ discard: true }).catch(() => {}); + doc = null; + await client?.dispose().catch(() => {}); + client = null; + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + tempDir = null; + } + }); + + test('exposes representative bound namespaces and methods', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'superdoc-docapi-smoke-')); + client = createSuperDocClient({ + requestTimeoutMs: 30_000, + startupTimeoutMs: 30_000, + shutdownTimeoutMs: 30_000, + env: { + SUPERDOC_CLI_BIN: CLI_BIN, + SUPERDOC_CLI_STATE_DIR: path.join(tempDir, '.superdoc-cli-state'), + }, + }); + + await client.connect(); + doc = await client.open({ sessionId: createSessionId('smoke-methods') }); + + expect(typeof doc.save).toBe('function'); + expect(typeof doc.close).toBe('function'); + expect(typeof doc.getText).toBe('function'); + expect(typeof doc.extract).toBe('function'); + expect(typeof doc.insert).toBe('function'); + expect(typeof doc.capabilities.get).toBe('function'); + expect(typeof doc.blocks.list).toBe('function'); + expect(typeof doc.lists.create).toBe('function'); + expect(typeof doc.lists.delete).toBe('function'); + expect(typeof doc.lists.merge).toBe('function'); + expect(typeof doc.lists.split).toBe('function'); + expect(typeof doc.tables.get).toBe('function'); + expect(typeof doc.tables.setCellText).toBe('function'); + expect(typeof doc.tables.applyPreset).toBe('function'); + expect(typeof doc.create.table).toBe('function'); + expect(typeof doc.customXml.parts.list).toBe('function'); + expect(typeof doc.customXml.parts.get).toBe('function'); + expect(typeof doc.customXml.parts.create).toBe('function'); + expect(typeof doc.customXml.parts.patch).toBe('function'); + expect(typeof doc.customXml.parts.remove).toBe('function'); + expect(typeof doc.metadata.attach).toBe('function'); + expect(typeof doc.metadata.list).toBe('function'); + expect(typeof doc.metadata.get).toBe('function'); + expect(typeof doc.metadata.update).toBe('function'); + expect(typeof doc.metadata.remove).toBe('function'); + expect(typeof doc.metadata.resolve).toBe('function'); + expect(typeof doc.selection.current).toBe('function'); + }); + + test('runs a representative SDK roundtrip workflow', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'superdoc-docapi-smoke-')); + const stateDir = path.join(tempDir, '.superdoc-cli-state'); + const savedDocPath = path.join(tempDir, 'roundtrip.docx'); + const insertedText = `Document API smoke ${Date.now()}`; + + client = createSuperDocClient({ + requestTimeoutMs: 30_000, + startupTimeoutMs: 30_000, + shutdownTimeoutMs: 30_000, + env: { + SUPERDOC_CLI_BIN: CLI_BIN, + SUPERDOC_CLI_STATE_DIR: stateDir, + }, + }); + + await client.connect(); + doc = await client.open({ sessionId: createSessionId('smoke-roundtrip') }); + + const capabilities = await doc.capabilities.get(); + expect(isRecord(capabilities)).toBe(true); + + await doc.insert({ value: insertedText }); + expect(await doc.getText()).toContain(insertedText); + + const paragraphNodeId = findBlockNodeId(await doc.blocks.list({ limit: 10 }), 'paragraph'); + await doc.lists.create({ + target: { kind: 'block', nodeType: 'paragraph', nodeId: paragraphNodeId }, + mode: 'fromParagraphs', + kind: 'bullet', + }); + + const lists = await doc.lists.list({ limit: 10 }); + expect(typeof lists.total).toBe('number'); + expect(lists.total).toBeGreaterThan(0); + + await doc.create.table({ + rows: 2, + columns: 2, + at: { kind: 'documentEnd' }, + }); + + await doc.save({ out: savedDocPath }); + await doc.close(); + doc = null; + + doc = await client.open({ + doc: savedDocPath, + sessionId: createSessionId('smoke-reopen'), + }); + + expect(await doc.getText()).toContain(insertedText); + + const reopenedLists = await doc.lists.list({ limit: 10 }); + expect(reopenedLists.total).toBeGreaterThan(0); + + const reopenedTableNodeId = findBlockNodeId(await doc.blocks.list({ limit: 20 }), 'table'); + const reopenedTable = await doc.tables.get({ nodeId: reopenedTableNodeId }); + expect(reopenedTable.rows).toBe(2); + expect(reopenedTable.columns).toBe(2); + }); +}); diff --git a/tests/document-api-smoke/vitest.config.ts b/tests/document-api-smoke/vitest.config.ts new file mode 100644 index 0000000000..46b3049004 --- /dev/null +++ b/tests/document-api-smoke/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'document-api-smoke', + environment: 'node', + include: ['src/**/*.test.ts'], + exclude: ['**/*.d.ts'], + testTimeout: 45_000, + }, +});