diff --git a/.changeset/better-deer-tap.md b/.changeset/better-deer-tap.md new file mode 100644 index 00000000..f51a28fe --- /dev/null +++ b/.changeset/better-deer-tap.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +Support both @stackoverflow/stacks v2 and v3 as peer dependencies diff --git a/.changeset/mean-tools-hammer.md b/.changeset/mean-tools-hammer.md new file mode 100644 index 00000000..03da4dd0 --- /dev/null +++ b/.changeset/mean-tools-hammer.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +Add beta publishing information to README diff --git a/.changeset/ninety-lizards-report.md b/.changeset/ninety-lizards-report.md new file mode 100644 index 00000000..589e62f4 --- /dev/null +++ b/.changeset/ninety-lizards-report.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +update stacks classic peer dependency to point to beta diff --git a/.changeset/odd-rules-jump.md b/.changeset/odd-rules-jump.md new file mode 100644 index 00000000..bc276e74 --- /dev/null +++ b/.changeset/odd-rules-jump.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +ensure editor continue to work with stacks v3 styles diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..28d02c65 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,15 @@ +{ + "mode": "exit", + "tag": "beta", + "initialVersions": { + "@stackoverflow/stacks-editor": "0.15.3" + }, + "changesets": [ + "better-deer-tap", + "mean-tools-hammer", + "ninety-lizards-report", + "odd-rules-jump", + "ripe-carpets-pull", + "wise-horses-wear" + ] +} diff --git a/.changeset/ripe-carpets-pull.md b/.changeset/ripe-carpets-pull.md new file mode 100644 index 00000000..97b6cdde --- /dev/null +++ b/.changeset/ripe-carpets-pull.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +update workflow file diff --git a/.changeset/stable-editor-release.md b/.changeset/stable-editor-release.md new file mode 100644 index 00000000..77d53b49 --- /dev/null +++ b/.changeset/stable-editor-release.md @@ -0,0 +1,7 @@ +--- +"@stackoverflow/stacks-editor": patch +--- + +Require stable Stacks Classic 3, preserve the established Editor presentation, +remove the obsolete link-preview demonstration, and prepare the final Editor +1.0 release. diff --git a/.changeset/wise-horses-wear.md b/.changeset/wise-horses-wear.md new file mode 100644 index 00000000..a73269ac --- /dev/null +++ b/.changeset/wise-horses-wear.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": major +--- + +enter prerelease mode for project shine diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ed58abb..eda75aed 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,12 @@ name: CI on: push: - branches: [main, beta] + branches: [main] pull_request: - branches: [main, beta] + branches: [main] + +permissions: + contents: read jobs: lint: @@ -38,6 +41,41 @@ jobs: - name: Run unit tests run: npm run test:unit + package-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install consumer test browser + run: npx playwright install --with-deps chromium + + - name: Test package consumer + run: npm run test:package + release-config-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run release configuration tests + run: npm run test:release-config e2e-test: runs-on: ubuntu-latest steps: @@ -64,9 +102,12 @@ jobs: name: playwright-test-results path: test-results/ release: - name: Release (latest or beta) - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' - needs: [lint, unit-test, e2e-test] + name: Release (latest) + if: github.ref == 'refs/heads/main' + needs: [lint, unit-test, e2e-test, package-test, release-config-test] + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -88,8 +129,8 @@ jobs: publish: npm run release title: "chore(new-release)" commit: "chore(new-release)" - branch: ${{ github.ref == 'refs/heads/beta' && 'beta' || null }} - createGithubReleases: ${{ github.ref == 'refs/heads/main' }} + branch: main + createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.STACKS_TOOLING_GH_RW_PAT }} NPM_TOKEN: ${{ secrets.NPM_API_KEY }} diff --git a/.prettierignore b/.prettierignore index dbccff9c..a0ee75d1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ .vscode/ stats.json dist/ -test-results/ \ No newline at end of file +test-results/ +.changeset/ diff --git a/CHANGELOG.md b/CHANGELOG.md index edf5c7ec..2787270c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 1.0.0-beta.5 + +### Minor Changes + +- [#505](https://github.com/StackExchange/Stacks-Editor/pull/505) [`4e0102a`](https://github.com/StackExchange/Stacks-Editor/commit/4e0102a6821436c5b307b747697aafa308229f4f) Thanks [@giamir](https://github.com/giamir)! - ensure editor continue to work with stacks v3 styles + +## 1.0.0-beta.4 + +### Patch Changes + +- [`2738c2e`](https://github.com/StackExchange/Stacks-Editor/commit/2738c2eae1e69dea5081df9f5c5a1fe22cac3119) Thanks [@giamir](https://github.com/giamir)! - Support both @stackoverflow/stacks v2 and v3 as peer dependencies + +## 1.0.0-beta.3 + +### Patch Changes + +- [`b9019be`](https://github.com/StackExchange/Stacks-Editor/commit/b9019bea1b4078016b352611edcf827c402adfd8) Thanks [@giamir](https://github.com/giamir)! - update stacks classic peer dependency to point to beta + +## 1.0.0-beta.2 + +### Patch Changes + +- [#482](https://github.com/StackExchange/Stacks-Editor/pull/482) [`d9ad150`](https://github.com/StackExchange/Stacks-Editor/commit/d9ad150ecf4cf0ae987fbb14020cb07fb17d20dd) Thanks [@ttaylor-stack](https://github.com/ttaylor-stack)! - update workflow file + +## 1.0.0-beta.1 + +### Patch Changes + +- [#475](https://github.com/StackExchange/Stacks-Editor/pull/475) [`73e4b6e`](https://github.com/StackExchange/Stacks-Editor/commit/73e4b6e0191b25518b52e015d63b4853ea330f34) Thanks [@ttaylor-stack](https://github.com/ttaylor-stack)! - Add beta publishing information to README + +## 1.0.0-beta.0 + +### Major Changes + +- enter prerelease mode for project shine + ## 0.15.4 ### Patch Changes diff --git a/README.md b/README.md index 239cd6f1..e54b07cb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ new StacksEditor( ## Run Tests +Use a Node release supported by the locked Playwright version. Install +dependencies with `npm ci` and the browser binaries with +`npx playwright install` before running browser tests. + Run all unit tests (no end-to-end tests) using npm run test:unit @@ -78,6 +82,24 @@ Run all end-to-end tests (written in Playwright) using End-to-end tests need to follow the convention of using `someName.e2e.test.ts` as their filename. They'll automatically get picked up by the test runner this way. +Verify the published package and release configuration using: + +```sh +npm run test:package +npm run test:release-config +``` + +The package check builds an npm tarball, installs it in a temporary consumer, +type-checks the public imports, and bundles its JavaScript and CSS. It then +opens the consumer in Chromium and checks editing and menu styling. Consumer +installation may access npm; direct Classic, Icons, and Highlight.js versions +match the repository lockfile. A missing Chromium binary fails the check. + +Menu end-to-end tests cover keyboard operation and layout in light, dark, +high-contrast, and dark high-contrast themes across all three browsers. +They save `heading-menu.png` review artifacts in `test-results/`. These are +not pixel-regression baselines or a complete accessibility audit. + ## Browser Bundle analysis Generate a `stats.json` file for analysis using @@ -88,13 +110,17 @@ You can upload your `stats.json` file [here](http://webpack.github.io/analyse/) ## Publishing -We use [changesets](https://github.com/changesets/changesets) to automatize the steps necessary to publish to NPM, create GH releases and a changelog. +We use [Changesets](https://github.com/changesets/changesets) to publish to npm, create GitHub Releases, and update the changelog. + +- Add a changeset to pull requests that require a package release. +- The release workflow creates and updates a release pull request against `main` while changesets are pending. +- Merging the reviewed release pull request publishes the package under npm's `latest` tag and creates a GitHub Release. +- The `v0` branch preserves supported Editor 0.15.x maintenance and publishes under the separate `legacy-v0` npm tag. -- Every time you do work that requires a new release to be published, [add a changesets entry](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md) by running `npx @changesets/cli` and follow the instructions on screen. (changes that do not require a new release - e.g. changing a test file - don't need a changeset). - - When opening a PR without a corresponding changeset the [changesets-bot](https://github.com/apps/changeset-bot) will remind you to do so. It generally makes sense to have one changeset for PR (if the PR changes do not require a new release to be published the bot message can be safely ignored) -- The release github job continuously check if there are new pending changesets in the main branch, if there are it creates a GH PR and continue updating it as more changesets are potentially pushed/merged to the main branch. -- When we are ready to cut a release we need to simply merge the `chore(release)` PR back to main and the release github workflow will take care of publishing the changes to NPM and create a GH release for us. The `chore(release)` PR also give us an opportunity to adjust the automatically generated changelog when necessary (the entry in the changelog file is also what will end up in the GH release notes). +_The release job runs only after lint, unit, end-to-end, packed-package, and release-configuration tests pass._ -_The release github job only run if the lint, unit-test and e2e-test jobs are all successful: this is to block accidental releases_. +Review the generated versions, dependency ranges, changelog, and package +contents before merging the release pull request. Its merge authorizes +publication; there is no additional manual approval step in the workflow. -_Despite using changesets to communicate the intent of creating releases in a more explicit way, we still follow [conventional commits standards](https://www.conventionalcommits.org/en/v1.0.0/) for keeping our git history easily parseable by the human eye._ +Continue using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for repository history. diff --git a/config/jest-unit.config.js b/config/jest-unit.config.js index 5818ce01..f3371fbe 100644 --- a/config/jest-unit.config.js +++ b/config/jest-unit.config.js @@ -6,7 +6,11 @@ module.exports = { "\\.svg$": "/test/__mocks__/svgMock.ts", }, rootDir: "../", - testPathIgnorePatterns: ["/node_modules/", String.raw`\.e2e\.test`], + testPathIgnorePatterns: [ + "/node_modules/", + "/dist/", + String.raw`\.e2e\.test`, + ], setupFilesAfterEnv: [ "/test/setup.ts", "/test/matchers.ts", diff --git a/package-lock.json b/package-lock.json index f43a39e8..086bd72d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stackoverflow/stacks-editor", - "version": "0.15.4", + "version": "1.0.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stackoverflow/stacks-editor", - "version": "0.15.4", + "version": "1.0.0-beta.5", "license": "MIT", "dependencies": { "@lezer/highlight": "^1.2.0", @@ -34,10 +34,10 @@ "@changesets/cli": "^2.28.1", "@commitlint/cli": "^19.2.1", "@commitlint/config-conventional": "^21.2.2", - "@playwright/test": "^1.42.1", + "@playwright/test": "^1.63.0", "@stackoverflow/commitlint-config": "^1.0.0", "@stackoverflow/prettier-config": "^1.0.0", - "@stackoverflow/stacks": "^2.9.0", + "@stackoverflow/stacks": "^3.0.0", "@stackoverflow/tsconfig": "^1.0.0", "@types/jest": "^29.5.12", "@types/markdown-it": "^14.0.0", @@ -63,6 +63,7 @@ "postcss-loader": "^8.1.1", "prettier": "^3.2.5", "prosemirror-test-builder": "^1.1.1", + "semver": "^7.6.3", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", "typescript": "^5.4.3", @@ -73,7 +74,7 @@ "webpack-merge": "^6.0.1" }, "peerDependencies": { - "@stackoverflow/stacks": "^2.3.0", + "@stackoverflow/stacks": "^3.0.0", "highlight.js": "^11.6.0" } }, @@ -2990,19 +2991,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@polka/url": { @@ -3110,9 +3111,9 @@ } }, "node_modules/@stackoverflow/stacks": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.9.0.tgz", - "integrity": "sha512-6VX9GTYFqsBFaelZ0sJ9YGZDa+1eLjr6Dh8r6cIh02SA/BcocWe0lxNZTcLpBQEKtX/9A0jTRsY3Au0BXcFJWQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-3.0.0.tgz", + "integrity": "sha512-pSyQ6IanWfv7KdjNsIN1lvCIL+OdGGsCzby/KD9NEUkcygBdjSJrzJbI692PCBIP3+ovIfQS/TdrrGbRedBB1w==", "dev": true, "dependencies": { "@hotwired/stimulus": "^3.2.2", @@ -7315,6 +7316,7 @@ "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -10784,50 +10786,32 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/postcss": { diff --git a/package.json b/package.json index be15023a..b275e8a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stackoverflow/stacks-editor", - "version": "0.15.4", + "version": "1.0.0-beta.5", "description": "", "repository": { "type": "git", @@ -43,6 +43,8 @@ "test": "npm run test:unit && npm run test:e2e", "test:unit": "jest --config config/jest-unit.config.js", "test:e2e": "npx playwright test --config config/playwright.config.ts", + "test:package": "npm run prepublishOnly && node --test scripts/package-artifact.test.mjs", + "test:release-config": "node --test scripts/stable-release-config.test.mjs", "release": "npm run build && changeset publish", "version": "changeset version && npm install --package-lock-only", "prepublishOnly": "npm run build && tsc --module ES6 --sourceMap false", @@ -69,10 +71,10 @@ "@commitlint/config-conventional": "^21.2.2", "@changesets/changelog-github": "^1.0.0", "@changesets/cli": "^2.28.1", - "@playwright/test": "^1.42.1", + "@playwright/test": "^1.63.0", "@stackoverflow/commitlint-config": "^1.0.0", "@stackoverflow/prettier-config": "^1.0.0", - "@stackoverflow/stacks": "^2.9.0", + "@stackoverflow/stacks": "^3.0.0", "@stackoverflow/tsconfig": "^1.0.0", "@types/jest": "^29.5.12", "@types/markdown-it": "^14.0.0", @@ -98,6 +100,7 @@ "postcss-loader": "^8.1.1", "prettier": "^3.2.5", "prosemirror-test-builder": "^1.1.1", + "semver": "^7.6.3", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", "typescript": "^5.4.3", @@ -129,7 +132,7 @@ "prosemirror-view": "^1.37.1" }, "peerDependencies": { - "@stackoverflow/stacks": "^2.3.0", + "@stackoverflow/stacks": "^3.0.0", "highlight.js": "^11.6.0" } } diff --git a/plugins/official/stack-snippets/src/snippet-view.ts b/plugins/official/stack-snippets/src/snippet-view.ts index 742c2739..0421e98a 100644 --- a/plugins/official/stack-snippets/src/snippet-view.ts +++ b/plugins/official/stack-snippets/src/snippet-view.ts @@ -89,14 +89,14 @@ export class StackSnippetView implements NodeView { ctas.className = "snippet-ctas d-flex ai-center"; if (opts && opts.renderer) { const snippetButtonContainer = document.createElement("div"); - snippetButtonContainer.className = "snippet-buttons mb0 gs4"; + snippetButtonContainer.className = "snippet-buttons d-flex mb0 g4"; ctas.appendChild(snippetButtonContainer); this.buildRunButton(snippetButtonContainer); this.buildEditButton(snippetButtonContainer); const snippetResultButtonContainer = document.createElement("div"); snippetResultButtonContainer.className = - "snippet-result-buttons d-flex mb0 ml-auto gs4"; + "snippet-result-buttons d-flex mb0 ml-auto g24"; ctas.appendChild(snippetResultButtonContainer); this.showButton = this.buildShowButton( snippetResultButtonContainer @@ -290,12 +290,12 @@ export class StackSnippetView implements NodeView { private buildRunButton(container: HTMLDivElement): void { const runCodeButton = document.createElement("button"); runCodeButton.type = "button"; - runCodeButton.className = "s-btn s-btn__filled flex--item"; + runCodeButton.className = "s-btn s-btn__icon flex--item"; runCodeButton.title = "Run code snippet"; runCodeButton.setAttribute("aria-label", "Run code snippet"); // create the svg svg-icon-bg element const runIcon = document.createElement("span"); - runIcon.className = "svg-icon-bg mr4 iconPlay"; + runIcon.className = "svg-icon-bg mr4 h16 iconPlay"; runCodeButton.append(runIcon); const runText = document.createElement("span"); runText.textContent = "Run code snippet"; @@ -353,7 +353,7 @@ export class StackSnippetView implements NodeView { private buildEditButton(container: HTMLDivElement): HTMLButtonElement { const editButton = document.createElement("button"); editButton.type = "button"; - editButton.className = "s-btn s-btn__outlined flex--item"; + editButton.className = "s-btn s-btn__clear flex--item"; editButton.title = "Edit code snippet"; editButton.setAttribute("aria-label", "Edit code snippet"); editButton.textContent = "Edit code snippet"; @@ -368,11 +368,11 @@ export class StackSnippetView implements NodeView { private buildHideButton(container: HTMLDivElement): HTMLButtonElement { const hideButton = document.createElement("button"); hideButton.type = "button"; - hideButton.className = "s-btn flex--item"; + hideButton.className = "s-btn s-btn__link d-flex flex--item"; hideButton.title = "Hide results"; hideButton.setAttribute("aria-label", "Hide results"); const hideIcon = document.createElement("span"); - hideIcon.className = "svg-icon-bg mr4 iconEyeOff"; + hideIcon.className = "svg-icon-bg iconEyeOff"; hideButton.append(hideIcon); const hideText = document.createElement("span"); hideText.textContent = "Hide results"; @@ -394,11 +394,11 @@ export class StackSnippetView implements NodeView { private buildShowButton(container: HTMLDivElement): HTMLButtonElement { const showButton = document.createElement("button"); showButton.type = "button"; - showButton.className = "s-btn flex--item d-none"; + showButton.className = "s-btn s-btn__link flex--item d-flex d-none"; showButton.title = "Show results"; showButton.setAttribute("aria-label", "Show results"); const hideIcon = document.createElement("span"); - hideIcon.className = "svg-icon-bg mr4 iconEye"; + hideIcon.className = "svg-icon-bg iconEye"; showButton.append(hideIcon); const showText = document.createElement("span"); showText.textContent = "Show results"; @@ -423,7 +423,7 @@ export class StackSnippetView implements NodeView { ): HTMLButtonElement { const expandButton = document.createElement("button"); expandButton.type = "button"; - expandButton.className = "s-btn flex--item"; + expandButton.className = "s-btn s-btn__link d-flex flex--item"; expandButton.title = "Expand Snippet"; expandButton.setAttribute("aria-label", "Expand Snippet"); expandButton.addEventListener("click", () => { @@ -436,7 +436,7 @@ export class StackSnippetView implements NodeView { ); }); const expandIcon = document.createElement("span"); - expandIcon.className = "svg-icon-bg mr4 iconShareSm"; + expandIcon.className = "svg-icon-bg iconShareSm"; expandButton.append(expandIcon); const expandText = document.createElement("span"); expandText.textContent = "Expand Snippet"; @@ -451,7 +451,8 @@ export class StackSnippetView implements NodeView { ): HTMLButtonElement { const collapseButton = document.createElement("button"); collapseButton.type = "button"; - collapseButton.className = "s-btn flex--item td-underline ml-auto"; + collapseButton.className = + "s-btn s-btn__link d-flex flex--item ml-auto"; collapseButton.title = "Return to post"; collapseButton.setAttribute("aria-label", "Return to post"); collapseButton.addEventListener("click", () => { @@ -464,7 +465,7 @@ export class StackSnippetView implements NodeView { ); }); const collapseIcon = document.createElement("span"); - collapseIcon.className = "svg-icon-bg mr4 iconShareSm"; + collapseIcon.className = "svg-icon-bg iconShareSm"; collapseButton.append(collapseIcon); const expandText = document.createElement("span"); expandText.textContent = "Return to post"; diff --git a/scripts/package-artifact.test.mjs b/scripts/package-artifact.test.mjs new file mode 100644 index 00000000..86bca62b --- /dev/null +++ b/scripts/package-artifact.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { chromium, expect } from "@playwright/test"; + +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const require = createRequire(import.meta.url); +const packageJson = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8") +); +const lock = JSON.parse( + await readFile(new URL("../package-lock.json", import.meta.url), "utf8") +); + +/** Run a consumer command with enough diagnostics to explain a CI failure. */ +const run = (command, args, cwd) => { + try { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + timeout: 120_000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error(`${error.message}\n${error.stdout}\n${error.stderr}`); + } +}; + +test("installs, type-checks, bundles, and runs the published package", async () => { + const root = await mkdtemp(path.join(tmpdir(), "stacks-editor-package-")); + let browser; + let server; + try { + const packed = JSON.parse( + run( + "npm", + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + root, + ], + repositoryRoot + ) + )[0]; + const packedFiles = new Set(packed.files.map(({ path }) => path)); + const exports = Object.values(packageJson.exports).flatMap((entry) => + typeof entry === "string" ? [entry] : Object.values(entry) + ); + for (const target of exports) { + assert.ok( + packedFiles.has(target.replace(/^\.\//, "")), + `${target} is missing from the tarball` + ); + } + + // Pin direct dependencies to the versions verified by this checkout. + // The consumer has no symlinks or resolver fallback to the source tree. + const dependencies = Object.fromEntries( + [ + "@stackoverflow/stacks", + "@stackoverflow/stacks-icons", + "highlight.js", + ].map((name) => [ + name, + lock.packages[`node_modules/${name}`].version, + ]) + ); + dependencies[packageJson.name] = `file:./${packed.filename}`; + await writeFile( + path.join(root, "package.json"), + JSON.stringify({ + name: "stacks-editor-packed-consumer", + private: true, + type: "module", + dependencies, + }) + ); + run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-offline", + ], + root + ); + + await writeFile( + path.join(root, "consumer.cjs"), + `const { EditorType, StacksEditor } = require("@stackoverflow/stacks-editor"); +window.commonJsExports = { EditorType, StacksEditor }; +window.commonJsEditor = new StacksEditor( + document.querySelector("#commonjs-editor"), + "CommonJS consumer smoke test", + { defaultView: EditorType.RichText } +); +` + ); + + const imports = ` +import { EditorType, StacksEditor } from "@stackoverflow/stacks-editor"; +import { codeDetectionPlugin } from "@stackoverflow/stacks-editor/plugins/sample"; +import { markdownLogging } from "@stackoverflow/stacks-editor/plugins/devx"; +import { stackSnippetPlugin } from "@stackoverflow/stacks-editor/plugins/official"; +`; + await writeFile( + path.join(root, "types.ts"), + `${imports} +declare const target: HTMLElement; +const editor = new StacksEditor(target, "types", { + defaultView: EditorType.RichText, + editorPlugins: [codeDetectionPlugin, markdownLogging, stackSnippetPlugin()], +}); +editor.content = "updated"; +` + ); + await writeFile( + path.join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + skipLibCheck: true, + noEmit: true, + }, + files: ["types.ts"], + }) + ); + run( + process.execPath, + [ + require.resolve("typescript/bin/tsc"), + "--project", + "tsconfig.json", + ], + root + ); + + await writeFile( + path.join(root, "consumer.js"), + `${imports} +import "@stackoverflow/stacks/dist/css/stacks.css"; +import "@stackoverflow/stacks-editor/dist/styles.css"; +window.consumerPlugins = [codeDetectionPlugin, markdownLogging, stackSnippetPlugin]; +window.editor = new StacksEditor(document.querySelector("#editor"), "Packed consumer smoke test", { + defaultView: EditorType.RichText, +}); +` + ); + await writeFile( + path.join(root, "webpack.config.cjs"), + ` +const MiniCssExtractPlugin = require(${JSON.stringify(require.resolve("mini-css-extract-plugin"))}); +module.exports = { + mode: "production", + context: ${JSON.stringify(root)}, + entry: { consumer: "./consumer.js", commonjs: "./consumer.cjs" }, + output: { path: ${JSON.stringify(path.join(root, "dist"))}, filename: "[name].js" }, + module: { rules: [{ test: /\\.css$/, use: [MiniCssExtractPlugin.loader, ${JSON.stringify(require.resolve("css-loader"))}] }] }, + plugins: [new MiniCssExtractPlugin({ filename: "consumer.css" })], +}; +` + ); + run( + process.execPath, + [ + require.resolve("webpack-cli/bin/cli.js"), + "--config", + "webpack.config.cjs", + ], + root + ); + await writeFile( + path.join(root, "dist/index.html"), + ` +Editor package consumer +
` + ); + await writeFile( + path.join(root, "dist/commonjs.html"), + ` +Editor CommonJS consumer +
` + ); + + const files = new Map([ + ["/", ["index.html", "text/html"]], + ["/commonjs.html", ["commonjs.html", "text/html"]], + ["/consumer.js", ["consumer.js", "text/javascript"]], + ["/commonjs.js", ["commonjs.js", "text/javascript"]], + ["/consumer.css", ["consumer.css", "text/css"]], + ]); + server = createServer(async (request, response) => { + const file = files.get(request.url); + if (!file) { + response.writeHead(404).end(); + return; + } + try { + response.writeHead(200, { "Content-Type": file[1] }); + response.end(await readFile(path.join(root, "dist", file[0]))); + } catch { + response.destroy(); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + // A missing browser must fail this release gate, never silently skip it. + browser = await chromium.launch(); + const page = await browser.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const editor = page.locator("#editor .ProseMirror"); + await expect(editor).toHaveCount(1); + await expect(editor).toHaveAttribute("contenteditable", "true"); + await expect(editor).toHaveText("Packed consumer smoke test"); + await editor.fill("Edited through the packed package"); + assert.match( + await page.evaluate(() => window.editor.content), + /Edited through the packed package/ + ); + const dropdown = page.locator('#editor [id^="heading-dropdown-btn-"]'); + await dropdown.click(); + const menu = page.locator('#editor [id^="heading-dropdown-popover-"]'); + await expect(menu).toBeVisible(); + await expect(menu.locator(".s-menu--action").first()).toHaveCSS( + "display", + "flex" + ); + + await page.goto( + `http://127.0.0.1:${server.address().port}/commonjs.html` + ); + const commonJsEditor = page.locator("#commonjs-editor .ProseMirror"); + await expect(commonJsEditor).toHaveText("CommonJS consumer smoke test"); + assert.deepEqual( + await page.evaluate(() => ({ + EditorType: typeof window.commonJsExports.EditorType, + StacksEditor: typeof window.commonJsExports.StacksEditor, + })), + { EditorType: "object", StacksEditor: "function" } + ); + assert.deepEqual(errors, []); + } finally { + await browser?.close(); + if (server?.listening) { + await new Promise((resolve) => server.close(resolve)); + } + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/stable-release-config.test.mjs b/scripts/stable-release-config.test.mjs new file mode 100644 index 00000000..9af48548 --- /dev/null +++ b/scripts/stable-release-config.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { describe, test } from "node:test"; +import semver from "semver"; +import jestConfig from "../config/jest-unit.config.js"; + +const readRepositoryFile = (path) => + readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +describe("stable release configuration", () => { + test("keeps emitted test copies out of unit-test discovery", () => { + const ignored = jestConfig.testPathIgnorePatterns.map( + (pattern) => new RegExp(pattern.replace("", "/editor")) + ); + assert.ok( + ignored.some((pattern) => + pattern.test( + "/editor/dist/plugins/official/stack-snippets/test/common.test.js" + ) + ) + ); + assert.ok( + ignored.every( + (pattern) => + !pattern.test( + "/editor/plugins/official/stack-snippets/test/common.test.ts" + ) + ) + ); + }); + + test("uses stable V3 package metadata", async () => { + const packageJson = JSON.parse( + await readRepositoryFile("package.json") + ); + const packageLock = JSON.parse( + await readRepositoryFile("package-lock.json") + ); + const version = semver.parse(packageJson.version); + + assert.equal(version?.major, 1); + assert.equal(packageLock.version, packageJson.version); + assert.equal(packageLock.packages[""].version, packageJson.version); + assert.equal( + packageJson.devDependencies["@stackoverflow/stacks"], + "^3.0.0" + ); + assert.equal( + packageJson.peerDependencies["@stackoverflow/stacks"], + "^3.0.0" + ); + assert.equal( + packageJson.dependencies["@stackoverflow/stacks-icons"], + "^6.2.0" + ); + }); + + test("publishes only from main after every release gate passes", async () => { + const workflow = await readRepositoryFile(".github/workflows/main.yml"); + const changesetConfig = JSON.parse( + await readRepositoryFile(".changeset/config.json") + ); + const codeowners = await readRepositoryFile(".github/CODEOWNERS"); + + assert.equal(workflow.match(/branches: \[main\]/g)?.length, 2); + assert.match(workflow, /if: github\.ref == 'refs\/heads\/main'/); + assert.match(workflow, /publish: npm run release/); + assert.match(workflow, /branch: main/); + assert.match(workflow, /createGithubReleases: true/); + assert.match(workflow, /^permissions:\n contents: read$/m); + assert.match( + workflow, + /release:\n(?:.|\n)*? permissions:\n contents: read\n pull-requests: write\n/m + ); + assert.match( + workflow, + /needs: \[lint, unit-test, e2e-test, package-test, release-config-test\]/ + ); + assert.doesNotMatch(workflow, /refs\/heads\/beta/); + assert.equal(changesetConfig.baseBranch, "main"); + assert.equal(codeowners.trim(), "* @StackExchange/stacks"); + }); + + test("has exited prerelease mode when prerelease state exists", async () => { + const preJsonPath = new URL("../.changeset/pre.json", import.meta.url); + const packageJson = JSON.parse( + await readRepositoryFile("package.json") + ); + + if (existsSync(preJsonPath)) { + const preState = JSON.parse(await readFile(preJsonPath, "utf8")); + + assert.equal(packageJson.version, "1.0.0-beta.5"); + assert.equal(preState.mode, "exit"); + assert.equal( + preState.initialVersions["@stackoverflow/stacks-editor"], + "0.15.3" + ); + assert.deepEqual(preState.changesets, [ + "better-deer-tap", + "mean-tools-hammer", + "ninety-lizards-report", + "odd-rules-jump", + "ripe-carpets-pull", + "wise-horses-wear", + ]); + assert.match( + await readRepositoryFile(".changeset/wise-horses-wear.md"), + /"@stackoverflow\/stacks-editor": major/ + ); + assert.match( + await readRepositoryFile(".changeset/stable-editor-release.md"), + /"@stackoverflow\/stacks-editor": patch/ + ); + } else { + assert.equal(packageJson.version, "1.0.0"); + assert.match( + await readRepositoryFile("CHANGELOG.md"), + /^## 1\.0\.0$/m + ); + } + }); + + test("removes beta branding from the stable site", async () => { + const layout = await readRepositoryFile("site/layout.html"); + const index = await readRepositoryFile("site/views/index.html"); + + assert.doesNotMatch(layout, /\[BETA\]|>BetaBeta { + const layout = await readRepositoryFile("site/layout.html"); + const menuHelpers = await readRepositoryFile( + "src/shared/menu/helpers.ts" + ); + + assert.doesNotMatch(layout, /s-block-link/); + assert.doesNotMatch(menuHelpers, /s-block-link/); + assert.match(layout, /s-menu--item/); + assert.match(menuHelpers, /s-menu--action/); + }); +}); diff --git a/site/index.ts b/site/index.ts index ff24ad1f..72e3203e 100644 --- a/site/index.ts +++ b/site/index.ts @@ -8,7 +8,6 @@ import { StacksEditorOptions, } from "../src"; import { PreviewRenderer } from "../src/commonmark/editor"; -import type { LinkPreviewProvider } from "../src/rich-text/plugins/link-preview"; import type { ImageUploadOptions } from "../src/shared/prosemirror-plugins/image-upload"; import { sleepAsync } from "../test/rich-text/test-helpers"; import { markdownLogging } from "../plugins/devx"; @@ -48,52 +47,6 @@ function setTimeoutAsync(delay: number): Promise { }); } -/** - * Sample preview provider attached to `example.com` domain that simulates - * a fetch by waiting five seconds from time of request to time of render - */ -export const ExampleLinkPreviewProvider: LinkPreviewProvider = { - domainTest: /^https?:\/\/(www\.)?(example\.com)/i, - renderer: (url: string) => { - let returnValue: string = null; - - // only render example.com urls, no matter what's registered downstream - if (url.includes("example.com")) { - const date = new Date().toString(); - // NOTE: usually we'd use escapeHTML here, but I don't want to pull in any of the bundle (for demo purposes) - returnValue = ` - `; - } - - return setTimeoutAsync(5000).then(() => { - const el = document.createElement("div"); - // Note: local development only, don't care to sanitize and don't want to import escapeHTML - // eslint-disable-next-line no-unsanitized/property - el.innerHTML = returnValue; - return el; - }); - }, -}; - -export const ExampleTextOnlyLinkPreviewProvider: LinkPreviewProvider = { - domainTest: /^https?:\/\/(www\.)?(example\.org)/i, - renderer: (url) => - setTimeoutAsync(0).then(() => - document.createTextNode(`Example domain (${new URL(url).pathname})`) - ), - textOnly: true, -}; - /** * Sample image handler that processes the uploaded image and returns a data url * rather than sending it to an external service @@ -314,10 +267,6 @@ domReady(() => { }, placeholderText: "This is placeholder text, so start typing…", richTextOptions: { - linkPreviewProviders: [ - ExampleTextOnlyLinkPreviewProvider, - ExampleLinkPreviewProvider, - ], highlighting: { highlightedNodeTypes: ["stack_snippet_lang"], languages: [ diff --git a/site/layout.html b/site/layout.html index 7de4b59b..ae206131 100644 --- a/site/layout.html +++ b/site/layout.html @@ -14,7 +14,7 @@