diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index b100bdeb80..f43d637751 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,51 +1,36 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml so that -# every stable release shares one concurrency slot and one git push lane. -# docs-stable is advanced by promote-stable-docs.yml when release-stable.yml -# produces a real superdoc v* tag (no-op runs do not advance docs-stable). -# Manual PR preview: dispatch with pr_number to publish @pr- -name: 📦 Release superdoc +# Publishes a PR preview of superdoc as `superdoc@pr-`. +# +# This workflow does NOT cut releases. `superdoc` on npm is published by two +# release lines that share one package name: V2 owns `latest` and `next`, and +# V1 is maintenance-only under `legacy`. V1 stable releases are orchestrated +# centrally by release-stable.yml, which publishes `superdoc` to `legacy`. +# +# It previously auto-released on every push to main and claimed `next`, which +# took that tag over from V2 whenever a V1 commit landed. There is no automatic +# trigger and no semantic-release path here for that reason — the only way to +# publish from this workflow is an explicit dispatch with a PR number, which +# can only ever produce a `pr-` tag. +name: 📦 Publish superdoc PR preview on: - push: - branches: - - main - paths: - - 'packages/superdoc/**' - - 'packages/layout-engine/**' - - 'packages/super-editor/**' - - 'packages/word-layout/**' - - 'packages/preset-geometry/**' - - 'shared/**' - - 'pnpm-workspace.yaml' - - '!**/*.md' workflow_dispatch: inputs: pr_number: - description: 'PR number to publish a preview package for (leave empty for normal release)' - required: false + description: 'PR number to publish a preview package for' + required: true type: number permissions: - contents: write + contents: read packages: write pull-requests: write concurrency: - # Stable releases share the `release-stable` group so @semantic-release/git - # pushes to `stable` serialize across workflows; per-workflow groups would - # let releases race on `git push origin stable`. queue: max keeps GitHub - # from dropping older pending stable releases when a stable push touches - # multiple wrapper packages; default queue: single only allows one pending. - # queue: max requires cancel-in-progress: false (cannot be combined with true). - group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ format('{0}-{1}', github.workflow, inputs.pr_number) }} cancel-in-progress: false - queue: max jobs: - release: - # Stable publishes must go through release-stable.yml; PR previews are still allowed. - if: ${{ github.event_name != 'workflow_dispatch' || github.ref_name != 'stable' || inputs.pr_number }} + preview: runs-on: ubuntu-24.04 steps: - name: Generate token @@ -55,9 +40,7 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - # PR preview: check out the PR branch - name: Get PR head ref - if: inputs.pr_number id: pr env: GH_TOKEN: ${{ github.token }} @@ -69,19 +52,9 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ steps.pr.outputs.sha || '' }} + ref: ${{ steps.pr.outputs.sha }} token: ${{ steps.generate_token.outputs.token }} - - name: Refresh branch head - # Queued release runs may start against a stale checkout (queue: max - # plus cancel-in-progress: false). Refresh to the current branch head - # so @semantic-release/git pushes fast-forward; semantic-release no-ops - # if no new commits were added since the previous queued run released. - if: ${{ !inputs.pr_number }} - run: | - git fetch origin "${{ github.ref_name }}" --tags - git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" - - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 @@ -106,9 +79,8 @@ jobs: - name: Install dependencies run: pnpm install - # PR preview: set version before build so it's baked into the package + # Set the version before build so it is baked into the package. - name: Set preview version - if: inputs.pr_number id: version run: | BASE_VERSION=$(node -p "require('./packages/superdoc/package.json').version") @@ -121,8 +93,8 @@ jobs: run: pnpm run build # Public-type contract gate: same coverage as PR CI (ci-superdoc.yml). - # Runs before publishing so a release cannot ship a regression that - # bypassed PR CI (manual republish, hotfix branch, recovery flow). + # Runs before publishing so a preview cannot ship a regression that + # bypassed PR CI. - name: SuperDoc public interface check # Wraps the nine wrapper stages: contract-tiers-test, # contract-tiers, jsdoc-ratchet, build (skipped here), @@ -132,9 +104,8 @@ jobs: # `pnpm run build` (which includes build:superdoc). run: pnpm check:public:superdoc --skip-build - # PR preview: publish with pr- dist-tag + # `--dist-tag pr-` is the only tag this workflow can publish. - name: Publish PR preview - if: inputs.pr_number env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -144,7 +115,6 @@ jobs: --skip-build - name: Comment on PR - if: inputs.pr_number env: GH_TOKEN: ${{ github.token }} run: | @@ -156,15 +126,3 @@ jobs: ``` EOF )" - - # Normal release: semantic-release (only when NOT a PR preview) - - name: Release - if: ${{ !inputs.pr_number }} - env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - working-directory: packages/superdoc - run: pnpx semantic-release diff --git a/apps/docs/scripts/validate-v1-package-pins.test.mjs b/apps/docs/scripts/validate-v1-package-pins.test.mjs index d493bae9b5..62da52c279 100644 --- a/apps/docs/scripts/validate-v1-package-pins.test.mjs +++ b/apps/docs/scripts/validate-v1-package-pins.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; const docsRoot = new URL('../', import.meta.url); const scannedExtensions = new Set(['.jsx', '.mdx', '.txt']); -const installCommand = /(?:npm (?:install|i)|pnpm add|bun add|yarn add)\s+([^\n]+)/gu; +const installCommand = /(?:npm (?:install|i|add)|pnpm add|bun add|yarn add)\s+([^\n]+)/gu; const staleNextTag = /(?:superdoc|@superdoc-dev\/react)@next\b/u; const wrongV1AssetPath = /superdoc@1\/dist-cdn\b/u; const browserPackageUrl = @@ -40,6 +40,7 @@ test('the guard rejects unpinned installs from supported package managers', () = const unsafeExamples = [ 'npm install superdoc', 'npm i superdoc', + 'npm add superdoc', 'pnpm add superdoc', 'bun add superdoc', 'yarn add superdoc', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js new file mode 100644 index 0000000000..ef8e49e625 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.integration.test.js @@ -0,0 +1,55 @@ +// Full importer-pipeline regression test mirroring ins-translator.integration.test.js +// for the w:del side of the same bug class (trackDelete only applied to +// content[0] when it was text). The fixture is derived from +// tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx by converting +// its two noBreakHyphen-leading tracked-insert runs (paragraph 16, w:id 2/3) +// into tracked-delete runs (/ per OOXML CT_RunTrackChange) — +// the original repro docx contains no w:del content of its own. +import { describe, it, expect, afterEach } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const findParagraphNode = (docJson, needle) => + (docJson.content || []).find((node) => node.type === 'paragraph' && JSON.stringify(node).includes(needle)); + +const flattenInlineContent = (paragraphNode) => + (paragraphNode.content || []).flatMap((runNode) => runNode.content || []); + +const hasTrackDeleteMark = (node) => (node.marks || []).some((mark) => mark.type === 'trackDelete'); + +describe('w:del importer-pipeline integration: run beginning with w:noBreakHyphen', () => { + let editor; + + afterEach(() => { + if (editor) { + editor.destroy(); + editor = null; + } + }); + + it('imports the noBreakHyphen atom and the text following it as one tracked deletion (paragraph 16)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-delete-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16 = findParagraphNode(docJson, 'Notwithstanding any other provision'); + expect(paragraph16).toBeTruthy(); + + const inlineNodes = flattenInlineContent(paragraph16); + // Only w:id 2/3 were converted to in this fixture (see the header + // comment); w:id 1 stays a plain tracked insertion, so scope assertions to + // the two converted runs' known text rather than every node in the paragraph. + const deletedTextNodes = inlineNodes.filter( + (node) => node.type === 'text' && (node.text.includes('tangible form') || node.text.includes('unaided memories')), + ); + expect(deletedTextNodes.length).toBe(2); + + const noBreakHyphenNodes = inlineNodes.filter((node) => node.type === 'noBreakHyphen'); + expect(noBreakHyphenNodes.length).toBe(2); + + [...noBreakHyphenNodes, ...deletedTextNodes].forEach((node) => { + expect(hasTrackDeleteMark(node)).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index 888ffc46ba..88a121fedd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -7,6 +7,7 @@ import { stampImportTrackingAttrs, withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; +import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -64,17 +65,7 @@ const encode = (params, encodedAttrs = {}) => { encodedAttrs.origin = converter.documentOrigin; } - subs.forEach((subElement) => { - subElement.marks = []; - if (subElement?.content?.[0]) { - if (subElement.content[0].marks === undefined) { - subElement.content[0].marks = []; - } - if (subElement.content[0].type === 'text') { - subElement.content[0].marks.push({ type: 'trackDelete', attrs: encodedAttrs }); - } - } - }); + applyTrackedMarkToRunContent(subs, 'trackDelete', encodedAttrs); return subs; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 643fc19441..d9fc2d20db 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -80,6 +80,64 @@ describe('w:del translator', () => { expect(getMarkAttrs(result)).toEqual(expect.objectContaining({ id: '123', sourceId: '123' })); }); + it('marks a leading non-text atom (e.g. noBreakHyphen) and the text that follows it', () => { + const mockSubNodes = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + + it('does not mark a non-whitelisted content child but still marks trailing text', () => { + const mockSubNodes = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toBeUndefined(); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackDelete', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + it('remaps id via trackedChangeIdMap and preserves sourceId', () => { const converter = { trackedChangeIdMap: new Map([['123', 'shared-uuid-abc']]), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js new file mode 100644 index 0000000000..cbfbf7f8eb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js @@ -0,0 +1,71 @@ +// Full importer-pipeline regression test for the "V1 w:ins loses tracking when +// a run begins with w:noBreakHyphen" bug. Goes through the real Editor import +// path (Editor.loadXmlData + new Editor(...)), not just a direct translator +// call, using a docx copied from plans/repro_tracked_insert_nbh.docx (that +// path is gitignored, so a tracked copy lives under tests/data/behavior-fixtures/). +import { describe, it, expect, afterEach } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const findParagraphNode = (docJson, needle) => + (docJson.content || []).find((node) => node.type === 'paragraph' && JSON.stringify(node).includes(needle)); + +const flattenInlineContent = (paragraphNode) => + (paragraphNode.content || []).flatMap((runNode) => runNode.content || []); + +const hasTrackInsertMark = (node) => (node.marks || []).some((mark) => mark.type === 'trackInsert'); + +describe('w:ins importer-pipeline integration: run beginning with w:noBreakHyphen', () => { + let editor; + + afterEach(() => { + if (editor) { + editor.destroy(); + editor = null; + } + }); + + it('imports the noBreakHyphen atom and the text following it as one tracked insertion (paragraph 16)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16 = findParagraphNode(docJson, 'Notwithstanding any other provision'); + expect(paragraph16).toBeTruthy(); + + const inlineNodes = flattenInlineContent(paragraph16); + const noBreakHyphenNodes = inlineNodes.filter((node) => node.type === 'noBreakHyphen'); + expect(noBreakHyphenNodes.length).toBeGreaterThan(0); + + // Every noBreakHyphen atom in this tracked paragraph must carry trackInsert. + noBreakHyphenNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + + // And so must every text node in the paragraph, including the text that + // follows a noBreakHyphen atom within the same run. + const textNodes = inlineNodes.filter((node) => node.type === 'text'); + expect(textNodes.length).toBeGreaterThan(0); + textNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + }); + + it('does not regress the control paragraph using plain hyphen-minus characters (paragraph 16B)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraph16B = findParagraphNode(docJson, 'control paragraph'); + expect(paragraph16B).toBeTruthy(); + + const textNodes = flattenInlineContent(paragraph16B).filter((node) => node.type === 'text'); + expect(textNodes.length).toBeGreaterThan(0); + textNodes.forEach((node) => { + expect(hasTrackInsertMark(node)).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 8d10c3805d..b4a020eba1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -7,6 +7,7 @@ import { stampImportTrackingAttrs, withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; +import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:ins'; @@ -63,17 +64,7 @@ const encode = (params, encodedAttrs = {}) => { encodedAttrs.origin = converter.documentOrigin; } - subs.forEach((subElement) => { - subElement.marks = []; - if (subElement?.content?.[0]) { - if (subElement.content[0].marks === undefined) { - subElement.content[0].marks = []; - } - if (subElement.content[0].type === 'text') { - subElement.content[0].marks.push({ type: 'trackInsert', attrs: encodedAttrs }); - } - } - }); + applyTrackedMarkToRunContent(subs, 'trackInsert', encodedAttrs); return subs; }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index 766a2c1de2..e1ac8110ad 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -82,6 +82,64 @@ describe('w:ins translator', () => { ]); }); + it('marks a leading non-text atom (e.g. noBreakHyphen) and the text that follows it', () => { + const mockSubNodes = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + + it('does not mark a non-whitelisted content child but still marks trailing text', () => { + const mockSubNodes = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'text' }], + }, + ]; + const mockNodeListHandler = { handler: vi.fn().mockReturnValue(mockSubNodes) }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(result[0].content[0].marks).toBeUndefined(); + expect(result[0].content[1].marks).toEqual([ + { type: 'trackInsert', attrs: expect.objectContaining({ author: 'Test' }) }, + ]); + }); + it('preserves the original Word ID as sourceId when no map exists', () => { const { result } = encodeWith(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js index 036b869bad..2c8a340062 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js @@ -63,6 +63,38 @@ export const prepareRunTrackingContext = (node = {}) => { }; }; +/** + * Content child types whose decode() path is confirmed to consult + * trackInsert/trackDelete marks on export (see no-break-hyphen-translator.js + * and t-translator.js). Widening this set requires adding the matching + * decode-side branch to that content type's own translator first — otherwise + * export would silently drop the tracked-change metadata. + */ +export const TRACKABLE_RUN_CONTENT_TYPES = new Set(['text', 'noBreakHyphen']); + +/** + * Stamp a tracked-change mark (trackInsert/trackDelete) onto every trackable + * content child of each encoded run, not just the first. A run imported from + * ``/`` may begin with an inline atom (e.g. ``) + * before its text — marking only content[0] when it happens to be text drops + * tracking for that atom and for any content after it. + * + * @param {Array<{ content?: Array> }>} subElements + * @param {string} markType + * @param {Record} attrs + */ +export const applyTrackedMarkToRunContent = (subElements = [], markType, attrs) => { + subElements.forEach((subElement) => { + subElement.marks = []; + const content = Array.isArray(subElement?.content) ? subElement.content : []; + content.forEach((child) => { + if (!child || !TRACKABLE_RUN_CONTENT_TYPES.has(child.type)) return; + if (child.marks === undefined) child.marks = []; + child.marks.push({ type: markType, attrs }); + }); + }); +}; + const mapTrackingAttrs = (mark, attrMap) => { const source = mark?.attrs || {}; const mapped = {}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js index ea96fb5c6b..5b0595aeb6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.test.js @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; import { TrackInsertMarkName, TrackDeleteMarkName } from '@extensions/track-changes/constants.js'; -import { ensureTrackedWrapper, prepareRunTrackingContext } from './track-change-helpers.js'; +import { + ensureTrackedWrapper, + prepareRunTrackingContext, + applyTrackedMarkToRunContent, + TRACKABLE_RUN_CONTENT_TYPES, +} from './track-change-helpers.js'; describe('track-change-helpers', () => { describe('prepareRunTrackingContext', () => { @@ -142,4 +147,57 @@ describe('track-change-helpers', () => { expect(result).toEqual([]); }); }); + + describe('applyTrackedMarkToRunContent', () => { + const attrs = { id: '123', author: 'Test' }; + + it('marks every trackable content child, not just the first', () => { + const subs = [ + { + content: [{ type: 'noBreakHyphen' }, { type: 'text', text: 'added text' }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackInsert', attrs); + + expect(subs[0].marks).toEqual([]); + expect(subs[0].content[0].marks).toEqual([{ type: 'trackInsert', attrs }]); + expect(subs[0].content[1].marks).toEqual([{ type: 'trackInsert', attrs }]); + }); + + it('skips content children whose type is not in the trackable whitelist', () => { + const subs = [ + { + content: [{ type: 'tab' }, { type: 'text', text: 'added text' }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackInsert', attrs); + + expect(subs[0].content[0].marks).toBeUndefined(); + expect(subs[0].content[1].marks).toEqual([{ type: 'trackInsert', attrs }]); + }); + + it('preserves existing marks on a content child and appends the tracked mark', () => { + const subs = [ + { + content: [{ type: 'text', text: 'added text', marks: [{ type: 'bold' }] }], + }, + ]; + + applyTrackedMarkToRunContent(subs, 'trackDelete', attrs); + + expect(subs[0].content[0].marks).toEqual([{ type: 'bold' }, { type: 'trackDelete', attrs }]); + }); + + it('handles an empty or missing content array without throwing', () => { + expect(() => applyTrackedMarkToRunContent([{ content: [] }, {}], 'trackInsert', attrs)).not.toThrow(); + }); + + it('exposes the whitelist as text and noBreakHyphen only', () => { + expect(TRACKABLE_RUN_CONTENT_TYPES.has('text')).toBe(true); + expect(TRACKABLE_RUN_CONTENT_TYPES.has('noBreakHyphen')).toBe(true); + expect(TRACKABLE_RUN_CONTENT_TYPES.has('tab')).toBe(false); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/tests/data/.gitignore b/packages/super-editor/src/editors/v1/tests/data/.gitignore index f05aafbcdf..44b837e26a 100644 --- a/packages/super-editor/src/editors/v1/tests/data/.gitignore +++ b/packages/super-editor/src/editors/v1/tests/data/.gitignore @@ -7,3 +7,5 @@ !diffing/ !diffing/word/ !diffing/word/** +!behavior-fixtures/ +!behavior-fixtures/** diff --git a/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx new file mode 100644 index 0000000000..2766d05554 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-nobreakhyphen.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx new file mode 100644 index 0000000000..07d07f2962 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-insert-nobreakhyphen.docx differ diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs index 5ea585af87..3695b9359c 100644 --- a/packages/superdoc/.releaserc.cjs +++ b/packages/superdoc/.releaserc.cjs @@ -42,15 +42,20 @@ Object.keys(require.cache) const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; const isLocalPreview = process.env.SUPERDOC_RELEASE_PREVIEW === '1'; +// Tag ownership: `superdoc` on npm is published by two release lines. V2 owns +// the default channels — `latest` for stable, `next` for previews — and V1 is +// maintenance-only under `legacy`. V1 must never claim `latest` or `next`: +// both lines publish the same package name, so a V1 release that claimed a V2 +// channel would silently take it over (last write wins). +// +// `main` is deliberately absent. V1 no longer ships prereleases, so there is no +// branch here that could produce a `next` release. PR previews are unaffected — +// they publish `pr-` directly through scripts/publish-superdoc.cjs and +// never run semantic-release. const branches = [ { name: 'stable', - channel: 'latest', // Only stable gets @latest - }, - { - name: 'main', - channel: 'next', - prerelease: 'next', + channel: 'legacy', // V1 maintenance line; V2 owns `latest` }, // Maintenance branches - channel defaults to branch name { diff --git a/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs b/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs new file mode 100644 index 0000000000..687a6b49cc --- /dev/null +++ b/scripts/__tests__/publish-superdoc-scoped-tag.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const require = createRequire(import.meta.url); +const { scopedTagFor } = require('../publish-superdoc.cjs'); + +// `superdoc` and `@harbour-enterprises/superdoc` ship from the same tarball but +// do not share a tag namespace. The unscoped name is contended: V2 owns +// `latest` and `next` there, so V1 stable releases publish to `legacy`. The +// scoped mirror is V1-only — nothing else advances it — so applying `legacy` +// to it as well would leave its `latest` frozen and quietly stop default +// installs from updating. + +test('a V1 stable release keeps the scoped mirror on latest', () => { + assert.equal(scopedTagFor('legacy'), 'latest'); +}); + +test('tags without contention pass through unchanged', () => { + // Previews and prereleases must mean the same thing under both names. + assert.equal(scopedTagFor('pr-1207'), 'pr-1207'); + assert.equal(scopedTagFor('next'), 'next'); + assert.equal(scopedTagFor('latest'), 'latest'); +}); + +test('only the V1 stable channel is remapped', () => { + // A guard against broadening the rule into "anything unrecognised -> latest", + // which would silently redirect a maintenance tag onto the default channel. + for (const tag of ['beta', 'alpha', 'canary', '1.45.x']) { + assert.equal(scopedTagFor(tag), tag); + } +}); + +test('the CLI entrypoint loads without a temporal dead zone', async () => { + // scopedTagFor is referenced by parseArgs, which runs before module.exports + // is evaluated. Declaring it below that point throws + // "Cannot access 'scopedTagFor' before initialization" on every direct + // invocation — the path release recovery uses. + const { readFileSync } = await import('node:fs'); + const source = readFileSync( + new URL('../publish-superdoc.cjs', import.meta.url), + 'utf8', + ); + assert.ok( + source.indexOf('const scopedTagFor') < source.indexOf('const parseArgs'), + 'scopedTagFor must be initialized before parseArgs references it', + ); +}); diff --git a/scripts/__tests__/release-dist-tags.test.mjs b/scripts/__tests__/release-dist-tags.test.mjs new file mode 100644 index 0000000000..db311e5d34 --- /dev/null +++ b/scripts/__tests__/release-dist-tags.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getDistTagForVersion } from '../release-dist-tags.mjs'; + +// The superdoc descriptor as release-local-stable.mjs declares it. V1 is the +// maintenance line for `superdoc`, so its stable releases land on `legacy`. +const SUPERDOC = { name: 'superdoc', stableDistTag: 'legacy' }; +// A package with no override, which keeps the conventional `latest`. +const REACT = { name: 'react' }; + +test('a superdoc stable version resolves to legacy, never latest', () => { + assert.equal(getDistTagForVersion('1.45.1', SUPERDOC), 'legacy'); + assert.equal(getDistTagForVersion('1.46.0', SUPERDOC), 'legacy'); + assert.equal(getDistTagForVersion('2.0.0', SUPERDOC), 'legacy'); +}); + +test('a package without an override keeps latest', () => { + assert.equal(getDistTagForVersion('1.16.1', REACT), 'latest'); + assert.equal(getDistTagForVersion('1.16.1', null), 'latest'); + assert.equal(getDistTagForVersion('1.16.1'), 'latest'); +}); + +test('prereleases resolve to next regardless of the package', () => { + assert.equal(getDistTagForVersion('1.45.1-next.2', SUPERDOC), 'next'); + assert.equal(getDistTagForVersion('2.4.0-next.10', REACT), 'next'); + assert.equal(getDistTagForVersion('2.4.0-next.10'), 'next'); +}); + +test('the recovery path cannot restore a V1 stable version to latest', () => { + // 1.45.1 is the release that took `latest` from V2 on 2026-07-30. A resumed + // publish of that exact version must not repeat it. + assert.notEqual(getDistTagForVersion('1.45.1', SUPERDOC), 'latest'); + assert.notEqual(getDistTagForVersion('1.45.1', SUPERDOC), 'next'); +}); diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 38ec47881e..716571c5df 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -299,9 +299,10 @@ test('stable release workflows serialize on the shared release-stable concurrenc // @semantic-release/git pushes to `stable` queue instead of racing on // `git push origin stable`. Per-workflow groups parallelize and leave // npm/PyPI tarballs published with no corresponding tag/commit pushed. + // `release-superdoc.yml` is intentionally absent: it publishes PR previews + // only and never releases from `stable`, so it has no stable run to serialize. const stableWorkflows = [ '.github/workflows/release-stable.yml', - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-react.yml', '.github/workflows/release-esign.yml', @@ -355,9 +356,9 @@ test('stable release workflows serialize on the shared release-stable concurrenc } // Workflows that no longer auto-fire on stable - the orchestrator is - // their single stable release path. + // their single stable release path. `release-superdoc.yml` is absent + // because it has no push trigger at all (PR previews are dispatch-only). const orchestratorOnlyOnStable = [ - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-react.yml', '.github/workflows/release-vscode-ext.yml', @@ -381,6 +382,8 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag // runs are never cancelled by a newer release run (each merge is a // release-worthy state). queue: max cannot be combined with // cancel-in-progress: true (validation error). + // `release-superdoc.yml` is absent: dispatch-only PR previews are keyed by + // PR number and have no stable run to queue behind. const releaseWorkflows = [ '.github/workflows/release-cli.yml', '.github/workflows/release-create.yml', @@ -390,7 +393,6 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag '.github/workflows/release-react.yml', '.github/workflows/release-sdk.yml', '.github/workflows/release-stable.yml', - '.github/workflows/release-superdoc.yml', '.github/workflows/release-template-builder.yml', '.github/workflows/release-vscode-ext.yml', ]; @@ -943,8 +945,10 @@ test('docs promotion supports manual workflow_dispatch with optional sha input', }); test('stable release workflows and commit filters include shared workspace coverage', async () => { + // `release-superdoc.yml` is absent: it has no path filters to cover because + // it has no push trigger. Its `.releaserc.cjs` commit filter is still checked + // below — the stable orchestrator relies on it. const workflowFiles = [ - '.github/workflows/release-superdoc.yml', '.github/workflows/release-fonts.yml', '.github/workflows/release-esign.yml', '.github/workflows/release-react.yml', diff --git a/scripts/publish-superdoc.cjs b/scripts/publish-superdoc.cjs index 4d6a123019..77460bf5bd 100644 --- a/scripts/publish-superdoc.cjs +++ b/scripts/publish-superdoc.cjs @@ -61,6 +61,15 @@ const ensureDist = () => { } }; +// The scoped mirror is a V1-only package: nothing else publishes it, and no +// V2 release line claims its dist-tags. So the tag split that protects the +// unscoped `superdoc` (V1 -> `legacy`, V2 owns `latest`) must not be applied +// here — it would freeze `@harbour-enterprises/superdoc@latest` at whatever +// shipped last and silently stop default installs from updating. +// +// `scopedDistTag` lets the caller keep the two apart. It defaults to the +// package's own tag so the PR-preview path (`pr-`) stays identical +// across both names. const publishScopedMirror = (packageJson, distTag, logger = console) => { const scopedName = '@harbour-enterprises/superdoc'; @@ -107,6 +116,7 @@ const publishScopedMirror = (packageJson, distTag, logger = console) => { const publishPackages = ({ distTag = 'latest', + scopedDistTag = distTag, publishUnscoped = true, build = true, logger = console @@ -129,9 +139,21 @@ const publishPackages = ({ } } - publishScopedMirror(packageJson, distTag, logger); + publishScopedMirror(packageJson, scopedDistTag, logger); }; +// The unscoped `superdoc` shares its npm name with the V2 release line, so V1 +// stable releases publish it to `legacy`. The scoped mirror has no such +// contention — mapping `legacy` onto it too would strand its `latest`. +const SCOPED_MIRROR_STABLE_TAG = 'latest'; +const V1_STABLE_DIST_TAG = 'legacy'; + +// The dist-tag the scoped mirror should use for a given unscoped tag. Only the +// V1 stable channel is remapped; previews and prereleases stay aligned so +// `pr-` and `next` mean the same thing under both names. +const scopedTagFor = (distTag) => + distTag === V1_STABLE_DIST_TAG ? SCOPED_MIRROR_STABLE_TAG : distTag; + const parseArgs = (argv) => { let distTag; let skipUnscoped = false; @@ -154,6 +176,7 @@ const parseArgs = (argv) => { return { distTag: resolvedTag, + scopedDistTag: scopedTagFor(resolvedTag), publishUnscoped: !skipUnscoped && process.env.SKIP_UNSCOPED_PUBLISH !== 'true', build: !skipBuild && process.env.SKIP_BUILD !== 'true' }; @@ -169,6 +192,9 @@ if (require.main === module) { } } +// The unscoped `superdoc` shares its npm name with the V2 release line, so V1 +// stable releases publish it to `legacy`. The scoped mirror has no such +// contention — mapping `legacy` onto it too would strand its `latest`. module.exports = { publish: async (pluginConfig, context) => { const { nextRelease, logger = console } = context; @@ -176,10 +202,12 @@ module.exports = { publishPackages({ distTag, + scopedDistTag: scopedTagFor(distTag), publishUnscoped: true, build: true, logger }); }, - publishPackages + publishPackages, + scopedTagFor }; diff --git a/scripts/release-dist-tags.mjs b/scripts/release-dist-tags.mjs new file mode 100644 index 0000000000..03506b1005 --- /dev/null +++ b/scripts/release-dist-tags.mjs @@ -0,0 +1,22 @@ +// Dist-tag resolution for stable releases and release recovery. +// +// Lives in its own module because release-local-stable.mjs runs the release +// loop at import time and so cannot be imported by a test. The rule below is +// the one that decides which npm dist-tag a recovered publish lands on, which +// makes it worth testing directly rather than by inspection. +// +// `superdoc` is published to one npm package name by two release lines. V2 +// owns `latest` and `next`; V1 is maintenance-only under `legacy`. Recovery +// derives its tag from the version string, not from the semantic-release +// channel, so a package whose stable releases do not belong on `latest` must +// say so via `stableDistTag` on its descriptor. Without that, a resumed V1 +// publish would put a V1 version back on `latest`. + +// npm dist-tag for a package version, given that package's descriptor. +// +// Prereleases always land on `next`. Stable versions land on the package's +// `stableDistTag` when it declares one, and on `latest` otherwise. +export function getDistTagForVersion(version, pkg = null) { + if (version.includes('-next.')) return 'next'; + return pkg?.stableDistTag ?? 'latest'; +} diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index 4a3cdf9e7f..ec863827dd 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -42,6 +42,7 @@ import { appendFileSync, cpSync, mkdtempSync, readFileSync, readdirSync, rmSync import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getDistTagForVersion } from './release-dist-tags.mjs'; import { listTags, pruneLocalOnlyReleaseTags, run, runSemanticRelease } from './release-local.mjs'; import { shouldRecoverPackageRelease } from './release-recovery-state.mjs'; @@ -188,10 +189,6 @@ function getPreviousMergedReleaseTag(pattern, currentTag, ref = 'HEAD') { return currentIndex === -1 ? '' : (tags[currentIndex + 1] ?? ''); } -function getDistTagForVersion(version) { - return version.includes('-next.') ? 'next' : 'latest'; -} - function getVersionFromTag(pkg, tag) { return tag.startsWith(pkg.tagPrefix) ? tag.slice(pkg.tagPrefix.length) : tag; } @@ -824,7 +821,7 @@ async function maybeRecoverIncompleteRelease(pkg, branchRef) { } const version = getVersionFromTag(pkg, latestTag); - const distTag = getDistTagForVersion(version); + const distTag = getDistTagForVersion(version, pkg); const state = await inspectPackageReleaseState(pkg, { tag: latestTag, version, @@ -951,6 +948,10 @@ const packages = [ tagPrefix: 'v', tagPattern: 'v[0-9]*', npmPackages: SUPERDOC_NPM_PACKAGES, + // V1 is the maintenance line for `superdoc`; V2 owns `latest` and `next`. + // Mirrors the `legacy` channel in packages/superdoc/.releaserc.cjs so a + // recovered publish lands on the same tag the release itself would use. + stableDistTag: 'legacy', resumePublish: resumeSuperdocPublish, }, { @@ -1083,7 +1084,7 @@ for (let index = 0; index < packages.length; index += 1) { if (newTags.length > 0) { const recoveryTag = newTags[0]; const recoveryVersion = getVersionFromTag(pkg, recoveryTag); - const recoveryDistTag = getDistTagForVersion(recoveryVersion); + const recoveryDistTag = getDistTagForVersion(recoveryVersion, pkg); try { console.log(`Attempting recovery for tagged ${pkg.name} release ${recoveryTag}.`);