Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 23 additions & 65 deletions .github/workflows/release-superdoc.yml
Original file line number Diff line number Diff line change
@@ -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-<number>
name: 📦 Release superdoc
# Publishes a PR preview of superdoc as `superdoc@pr-<number>`.
#
# 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-<number>` 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
Expand All @@ -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 }}
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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),
Expand All @@ -132,9 +104,8 @@ jobs:
# `pnpm run build` (which includes build:superdoc).
run: pnpm check:public:superdoc --skip-build

# PR preview: publish with pr-<number> dist-tag
# `--dist-tag pr-<number>` 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 }}
Expand All @@ -144,7 +115,6 @@ jobs:
--skip-build

- name: Comment on PR
if: inputs.pr_number
env:
GH_TOKEN: ${{ github.token }}
run: |
Expand All @@ -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
3 changes: 2 additions & 1 deletion apps/docs/scripts/validate-v1-package-pins.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (<w:del>/<w:delText> 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 <w:del> 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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']]),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading