diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d57daac6ead..6378b02d7f3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,26 +1,27 @@ version: 2 updates: - - package-ecosystem: "npm" + - package-ecosystem: npm directory: "/" - open-pull-requests-limit: 10 schedule: interval: "weekly" + open-pull-requests-limit: 20 labels: - - "dependencies" - ignore: - - dependency-name: "react" - - dependency-name: "react-dom" - - dependency-name: "react-router-dom" - - dependency-name: "@docsearch/react" - - dependency-name: "tailwindcss" + - dependencies + versioning-strategy: widen groups: dependencies: patterns: - "*" + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 20 + labels: + - dependencies groups: dependencies: patterns: diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 00000000000..91df9252dfc --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,38 @@ +name: Dependabot + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot-auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Generate Token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + id: app-token + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + - name: Dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: "${{ steps.app-token.outputs.token }}" + + - name: Enable auto-merge for Dependabot PRs + if: steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' + run: | + if [ "$(gh pr status --json reviewDecision -q .currentBranch.reviewDecision)" != "APPROVED" ]; + then gh pr review --approve "$PR_URL" + else echo "PR already approved, skipping additional approvals to minimize emails/notification noise."; + fi + + gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b5088960f1c..3e183e1f0ba 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,4 +11,4 @@ jobs: - name: "Checkout Repository" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Dependency Review" - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 34910c2aad0..3ac41b773de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 68ac69523b1..f27bd1d95f6 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: yarn @@ -38,13 +38,14 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: yarn - run: yarn --frozen-lockfile - run: yarn lint:js + - run: yarn lint:prettier - run: yarn lint:markdown proseLint: @@ -58,7 +59,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: yarn @@ -84,7 +85,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: yarn @@ -95,7 +96,7 @@ jobs: uses: ./.github/actions/webpack-persistent-cache - name: Cypress run - uses: cypress-io/github-action@bc22e01685c56e89e7813fd8e26f33dc47f87e15 # v7.1.5 + uses: cypress-io/github-action@783cb3f07983868532cabaedaa1e6c00ff4786a8 # v7.1.9 with: browser: chrome config-file: cypress.config.js diff --git a/.vale/Speciesism/AnimalIdioms.yml b/.vale/Speciesism/AnimalIdioms.yml index d85fc0f0667..8041dde361c 100644 --- a/.vale/Speciesism/AnimalIdioms.yml +++ b/.vale/Speciesism/AnimalIdioms.yml @@ -4,29 +4,29 @@ link: https://doi.org/10.1007/s43681-023-00380-w level: warning ignorecase: true swap: - 'kill two birds with one stone': accomplish two things at once - 'killing two birds with one stone': accomplishing two things at once - 'killed two birds with one stone': accomplished two things at once - 'beat a dead horse': belabor the point - 'beating a dead horse': belaboring the point - 'flog a dead horse': belabor the point - 'flogging a dead horse': belaboring the point - 'bring home the bacon': bring home the results - 'bringing home the bacon': bringing home the results - 'brought home the bacon': brought home the results - 'more than one way to skin a cat': more than one way to solve this - 'many ways to skin a cat': many ways to approach this - 'let the cat out of the bag': reveal the secret - 'letting the cat out of the bag': revealing the secret - 'open a can of worms': create a complicated situation - 'opening a can of worms': creating a complicated situation - 'opened a can of worms': created a complicated situation - 'wild goose chase': pointless pursuit - 'take the bull by the horns': face the challenge head-on - 'taking the bull by the horns': facing the challenge head-on - 'took the bull by the horns': faced the challenge head-on - 'like shooting fish in a barrel': extremely easy - 'straight from the horse''s mouth': directly from the source - 'from the horse''s mouth': from a reliable source - 'whack-a-mole': recurring problem - 'whack a mole': recurring problem + "kill two birds with one stone": accomplish two things at once + "killing two birds with one stone": accomplishing two things at once + "killed two birds with one stone": accomplished two things at once + "beat a dead horse": belabor the point + "beating a dead horse": belaboring the point + "flog a dead horse": belabor the point + "flogging a dead horse": belaboring the point + "bring home the bacon": bring home the results + "bringing home the bacon": bringing home the results + "brought home the bacon": brought home the results + "more than one way to skin a cat": more than one way to solve this + "many ways to skin a cat": many ways to approach this + "let the cat out of the bag": reveal the secret + "letting the cat out of the bag": revealing the secret + "open a can of worms": create a complicated situation + "opening a can of worms": creating a complicated situation + "opened a can of worms": created a complicated situation + "wild goose chase": pointless pursuit + "take the bull by the horns": face the challenge head-on + "taking the bull by the horns": facing the challenge head-on + "took the bull by the horns": faced the challenge head-on + "like shooting fish in a barrel": extremely easy + "straight from the horse's mouth": directly from the source + "from the horse's mouth": from a reliable source + "whack-a-mole": recurring problem + "whack a mole": recurring problem diff --git a/.vale/Speciesism/AnimalMetaphors.yml b/.vale/Speciesism/AnimalMetaphors.yml index 6526f66f06f..cad2d056744 100644 --- a/.vale/Speciesism/AnimalMetaphors.yml +++ b/.vale/Speciesism/AnimalMetaphors.yml @@ -4,11 +4,11 @@ link: https://doi.org/10.1007/s43681-023-00380-w level: warning ignorecase: true swap: - 'guinea pig': test subject - 'sacred cow': unquestioned belief - 'sacred cows': unquestioned beliefs - 'scapegoat(?:ed|ing)?': wrongly blamed - 'dog-eat-dog': ruthlessly competitive - 'dog eat dog': ruthlessly competitive - 'rat race': competitive grind - 'red herring': false lead + "guinea pig": test subject + "sacred cow": unquestioned belief + "sacred cows": unquestioned beliefs + "scapegoat(?:ed|ing)?": wrongly blamed + "dog-eat-dog": ruthlessly competitive + "dog eat dog": ruthlessly competitive + "rat race": competitive grind + "red herring": false lead diff --git a/.vale/Speciesism/TechTerminology.yml b/.vale/Speciesism/TechTerminology.yml index 62949b5418a..d44a561b978 100644 --- a/.vale/Speciesism/TechTerminology.yml +++ b/.vale/Speciesism/TechTerminology.yml @@ -3,11 +3,11 @@ message: "Consider using '%s' instead of '%s'. This technical term has a more pr level: suggestion ignorecase: true swap: - 'canary deployment': progressive rollout - 'canary release': progressive rollout - 'canary test(?:ing)?': incremental testing - 'monkey[- ]?patch(?:ed|ing)?': runtime patch - 'duck[- ]?typ(?:ed|ing)': structural typing - 'dogfood(?:ing)?': self-hosting - 'eat(?:ing)? (?:your|our|their) own dogfood': self-testing - 'rubber duck(?:ing)? debugging': talk-through debugging + "canary deployment": progressive rollout + "canary release": progressive rollout + "canary test(?:ing)?": incremental testing + "monkey[- ]?patch(?:ed|ing)?": runtime patch + "duck[- ]?typ(?:ed|ing)": structural typing + "dogfood(?:ing)?": self-hosting + "eat(?:ing)? (?:your|our|their) own dogfood": self-testing + "rubber duck(?:ing)? debugging": talk-through debugging diff --git a/cypress/e2e/click-menu-scroll-top.cy.js b/cypress/e2e/click-menu-scroll-top.cy.js index 71278044128..08de6118caf 100644 --- a/cypress/e2e/click-menu-scroll-top.cy.js +++ b/cypress/e2e/click-menu-scroll-top.cy.js @@ -7,7 +7,8 @@ describe("Click menu", () => { // note that there's no hash in url cy.get('[data-testid="contributors"]').scrollIntoView(); - const selector = '.sidebar-item__title[href="/concepts/modules/"]'; + const selector = + '[data-testid="sidebar-item-title"][href="/concepts/modules/"]'; cy.get(selector).click(); cy.window().then((win) => { diff --git a/cypress/e2e/code-block-with-copy.cy.js b/cypress/e2e/code-block-with-copy.cy.js index e9d49b8f8cd..178a2ab83a7 100644 --- a/cypress/e2e/code-block-with-copy.cy.js +++ b/cypress/e2e/code-block-with-copy.cy.js @@ -64,6 +64,9 @@ describe("CodeBlockWithCopy", () => { it("copies non-diff code blocks without altering content", () => { visitWithClipboardSpy("/concepts/"); + // Wait for Suspense content to load before querying code blocks + cy.get("button.copy-button").should("exist"); + // Select the first webpack.config.js example and its copy wrapper. getFirstWebpackConfigBlock("standardCodeBlock"); @@ -73,6 +76,7 @@ describe("CodeBlockWithCopy", () => { .find("code") .invoke("text") .as("expectedCopiedText"); + cy.get("@standardCodeBlock").find("button.copy-button").should("exist"); cy.get("@standardCodeBlock").find("button.copy-button").click(); // Assert copied output is unchanged for regular code blocks. @@ -82,7 +86,7 @@ describe("CodeBlockWithCopy", () => { cy.get("@expectedCopiedText").then((expectedCopiedText) => { expect(copiedText).to.eq(expectedCopiedText); - expect(copiedText).to.include("module.exports = {"); + expect(copiedText).to.include("export default {"); expect(copiedText).to.include('entry: "./path/to/my/entry/file.js",'); }); }); diff --git a/cypress/e2e/page-hash-navigation.cy.js b/cypress/e2e/page-hash-navigation.cy.js new file mode 100644 index 00000000000..efa0512dfee --- /dev/null +++ b/cypress/e2e/page-hash-navigation.cy.js @@ -0,0 +1,16 @@ +"use strict"; + +describe("Page hash navigation", () => { + it("scrolls to the element specified by the hash", () => { + cy.visit("/guides/getting-started/#basic-setup"); + + cy.location("hash").should("eq", "#basic-setup"); + + cy.get("#basic-setup", { timeout: 10000 }) + .should("exist") + .then(($el) => { + const rect = $el[0].getBoundingClientRect(); + expect(rect.top).to.be.lessThan(200); + }); + }); +}); diff --git a/cypress/e2e/pr_4435.cy.js b/cypress/e2e/pr_4435.cy.js index 3d8ae80ac4b..e2e1007e9ff 100644 --- a/cypress/e2e/pr_4435.cy.js +++ b/cypress/e2e/pr_4435.cy.js @@ -9,16 +9,21 @@ describe("Open page in new tab", { scrollBehavior: false }, () => { cy.stub(win, "scrollTo"); }, }); + // wait for page content to load before asserting scroll + cy.get( + '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]', + ).should("exist"); // there's one call in Page.jsx when componentDidMount - cy.window().then((win) => { + cy.window().should((win) => { expect(win.scrollTo).to.be.calledOnce; }); - const selector = '.sidebar-item__title[href="/concepts/plugins/"]'; + const selector = + '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]'; // we click the menu cy.get(selector).click(); - cy.window().then((win) => { + cy.window().should((win) => { // we don't know whether user has scrolled the page or not although no pathname changed expect(win.scrollTo).to.be.calledTwice; }); @@ -28,7 +33,7 @@ describe("Open page in new tab", { scrollBehavior: false }, () => { metaKey: true, }); // no scrollTo should be called - cy.window().then((win) => { + cy.window().should((win) => { expect(win.scrollTo).to.be.calledTwice; }); } else if (Cypress.platform === "win32" || Cypress.platform === "linux") { @@ -37,14 +42,14 @@ describe("Open page in new tab", { scrollBehavior: false }, () => { ctrlKey: true, }); // no scrollTo should be called - cy.window().then((win) => { + cy.window().should((win) => { expect(win.scrollTo).to.be.calledTwice; }); } // we click the menu again, scroll to top again cy.get(selector).click(); - cy.window().then((win) => { + cy.window().should((win) => { expect(win.scrollTo).to.be.calledThrice; }); }); diff --git a/cypress/e2e/search.cy.js b/cypress/e2e/search.cy.js index 65f85ca3fbb..a1212fb9a73 100644 --- a/cypress/e2e/search.cy.js +++ b/cypress/e2e/search.cy.js @@ -1,10 +1,30 @@ "use strict"; describe("Search", () => { + beforeEach(() => { + cy.intercept("POST", "https://*.algolia.net/**", { + statusCode: 200, + body: { + results: [ + { + hits: [ + { + url: "http://localhost:3000/concepts/", + hierarchy: { lvl0: "Concepts" }, + objectID: "1", + }, + ], + }, + ], + }, + }).as("algoliaSearch"); + }); + it("should visit entry page", () => { cy.visit("/concepts/"); cy.get(".DocSearch").click(); cy.get("#docsearch-input").type("roadmap"); - cy.get(".DocSearch-Hits").should("be.visible"); + + cy.get(".DocSearch-Modal").should("be.visible"); }); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 7fc04bdf812..9d72898e188 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ export default defineConfig([ ".github/**/*.md", "**/README.md", "src/mdx-components.mjs", + "**/setupTests.js", ]), { extends: [configs.recommended], diff --git a/jest.config.mjs b/jest.config.mjs index eea8faaf2ca..3540d474c0f 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,12 +1,14 @@ export default { verbose: true, testEnvironment: "node", + setupFiles: ["./src/setupTests.js"], transform: { - "^.+\\.jsx?$": "babel-jest", + "^.+\\.(m|c)?jsx?$": "babel-jest", }, moduleNameMapper: { "\\.(scss|css)$": "/src/components/__mocks__/styleMock.js", "\\.svg$": "/src/components/__mocks__/svgMock.js", + "\\.(png|jpg|jpeg|ico)$": "/src/components/__mocks__/fileMock.js", }, moduleFileExtensions: [ "js", diff --git a/package.json b/package.json index 7c426bbc59a..598788588c0 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "main": "n/a", "scripts": { "clean-dist": "rimraf ./dist", - "clean-printable": "rimraf src/content/**/printable.mdx", + "clean-printable": "rimraf \"src/content/**/printable.mdx\"", "preclean": "run-s clean-dist clean-printable", - "clean": "rimraf src/content/**/_*.mdx src/**/_*.json repositories/*.json", + "clean": "rimraf \"src/content/**/_*.mdx\" \"src/**/_*.json\" \"repositories/*.json\"", "start": "npm run clean-dist && webpack serve --config webpack.dev.mjs --env dev --progress --config-node-env development", "content": "node src/scripts/build-content-tree.mjs ./src/content ./src/_content.json", "bundle-analyze": "run-s clean fetch content && webpack --config webpack.prod.mjs --config-node-env production && run-s printable content && webpack --config webpack.ssg.mjs --config-node-env production --env ssg --profile --json > stats.json && webpack-bundle-analyzer stats.json", @@ -37,13 +37,13 @@ "prebuild": "npm run clean", "build": "run-s fetch-repos fetch content && webpack --config webpack.prod.mjs --config-node-env production && run-s printable content && webpack --config webpack.ssg.mjs --config-node-env production --env ssg", "postbuild": "npm run sitemap && npm run rss", - "build-test": "npm run build && http-server --port 3000 dist/", - "serve-dist": "http-server --port 3000 dist/", + "build-test": "npm run build && http-server --silent --port 3000 dist/", + "serve-dist": "http-server --silent --port 3000 dist/", "test": "run-s lint jest", "lint": "run-s lint:*", "lint:prettier": "prettier --cache --list-different --ignore-unknown .", "lint:js": "eslint --cache --cache-location .cache/.eslintcache .", - "lint:markdown": "markdownlint --config ./.markdownlint.json '**/*.{md,mdx}'", + "lint:markdown": "markdownlint --config ./.markdownlint.json --rules ./src/utilities/markdown-lint-enforce-lang-aliases.js '**/*.{md,mdx}'", "lint:prose": "vale --config='.vale.ini' src/content", "lint:links": "hyperlink -c 8 --root dist -r dist/index.html --canonicalroot https://webpack.kr/ --internal --skip '%E' --skip /plugins/extract-text-webpack-plugin/ --skip /printable --skip /contribute/Governance --skip https:// --skip http:// --skip sw.js --skip /vendor > internal-links.tap; cat internal-links.tap | tap-spot", "sitemap": "cd dist && sitemap-static --ignore-file=../sitemap-ignore.json --pretty --prefix=https://webpack.kr/ > sitemap.xml", @@ -64,7 +64,7 @@ "*.{md,mdx}": [ "npm run lint:markdown" ], - "*.{js,mjs,jsx,css,scss,md,mdx,json}": [ + "*.{js,mjs,jsx,css,md,mdx,json}": [ "prettier --write" ] }, @@ -73,17 +73,19 @@ "eval": "^0.1.5" }, "dependencies": { - "@docsearch/react": "^3.9.0", + "@docsearch/react": "^4.6.0", "@react-spring/web": "^10.0.3", + "clsx": "^2.1.1", "path-browserify": "^1.0.1", "prop-types": "^15.8.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-helmet-async": "^2.0.5", - "react-router-dom": "^6.28.0", - "react-tiny-popover": "5", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-helmet-async": "^3.0.0", + "react-router": "^7.13.1", + "react-router-dom": "^7.13.1", + "react-tiny-popover": "^8.1.6", "react-use": "^17.6.0", - "react-visibility-sensor": "^5.0.2", + "tailwind-merge": "^3.5.0", "webpack-pwa-manifest": "^4.3.0", "workbox-window": "^7.4.0" }, @@ -91,25 +93,28 @@ "@babel/core": "^7.29.0", "@babel/plugin-proposal-class-properties": "^7.17.12", "@babel/preset-env": "^7.29.0", - "@babel/preset-react": "^7.27.6", + "@babel/preset-react": "^7.28.5", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@octokit/auth-action": "^6.0.2", "@octokit/rest": "^22.0.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@svgr/webpack": "^8.1.0", - "autoprefixer": "^10.4.24", - "babel-loader": "^10.0.0", - "copy-webpack-plugin": "^13.0.1", + "@tailwindcss/postcss": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "autoprefixer": "^10.4.27", + "babel-loader": "^10.1.1", + "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.3", - "css-minimizer-webpack-plugin": "^7.0.4", - "cypress": "^15.9.0", + "css-minimizer-webpack-plugin": "^8.0.0", + "cypress": "^15.12.0", "directory-tree": "^3.6.0", "directory-tree-webpack-plugin": "^1.0.3", "duplexer": "^0.1.1", "eslint": "^9.39.2", "eslint-config-webpack": "^4.9.3", - "eslint-plugin-cypress": "^5.2.1", + "eslint-plugin-cypress": "^6.1.0", "eslint-plugin-mdx": "^3.6.2", "feed": "^5.0.0", "front-matter": "^4.0.2", @@ -119,23 +124,23 @@ "http-server": "^14.1.1", "husky": "^9.1.7", "hyperlink": "^5.0.4", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", - "lightningcss": "^1.31.1", - "lint-staged": "^16.2.7", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", + "lightningcss": "^1.32.0", + "lint-staged": "^16.3.3", "lodash": "^4.17.23", - "markdownlint-cli": "^0.47.0", + "markdownlint-cli": "^0.48.0", "mdast-util-to-string": "^4.0.0", - "mini-css-extract-plugin": "^2.10.0", + "mini-css-extract-plugin": "^2.10.1", "mkdirp": "^3.0.1", - "modularscale-sass": "^3.0.3", "npm-run-all": "^4.1.1", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "postcss-loader": "^8.2.0", "prettier": "^3.8.1", + "prismjs": "^1.30.0", "react-refresh": "^0.18.0", - "react-test-renderer": "^17.0.2", "redirect-webpack-plugin": "^1.0.0", + "refractor": "^5.0.0", "remark": "^15.0.1", "remark-autolink-headings": "7.0.1", "remark-emoji": "^5.0.2", @@ -143,20 +148,17 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-html": "^16.0.1", - "remark-refractor": "montogeek/remark-refractor", "rimraf": "^6.1.2", - "sass": "^1.97.2", - "sass-loader": "^16.0.6", "sirv-cli": "^3.0.1", "sitemap-static": "^0.4.2", "static-site-generator-webpack-plugin": "^3.4.1", "style-loader": "^4.0.0", - "tailwindcss": "^3.4.16", + "tailwindcss": "^4.2.1", "tap-spot": "^1.1.2", "unist-util-visit": "^5.1.0", "webpack": "^5.105.0", "webpack-bundle-analyzer": "^5.2.0", - "webpack-cli": "^6.0.1", + "webpack-cli": "^7.0.0", "webpack-dev-server": "^5.2.3", "webpack-merge": "^6.0.1", "workbox-webpack-plugin": "^7.4.0", diff --git a/postcss.config.js b/postcss.config.js index 989a8c7d4ac..4a574f700c1 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,5 @@ "use strict"; module.exports = { - plugins: [require("tailwindcss"), require("autoprefixer")], + plugins: ["@tailwindcss/postcss", "autoprefixer"], }; diff --git a/src/AnalyticsRouter.jsx b/src/AnalyticsRouter.jsx deleted file mode 100644 index ab663d679ae..00000000000 --- a/src/AnalyticsRouter.jsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * based on https://github.com/seeden/react-g-analytics - * refactored against new version of react/react-router-dom - */ -import PropTypes from "prop-types"; -import { useEffect } from "react"; -import { BrowserRouter, useLocation } from "react-router-dom"; - -export default function AnalyticsRouter(props) { - const { id, set, children } = props; - - return ( - - - {children} - - - ); -} - -AnalyticsRouter.propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - set: PropTypes.object, -}; - -function loadScript() { - const gads = document.createElement("script"); - gads.async = true; - gads.type = "text/javascript"; - gads.src = "//www.google-analytics.com/analytics.js"; - - const [head] = document.getElementsByTagName("head"); - head.appendChild(gads); -} - -function initGoogleAnalytics(id, set) { - if (window.ga || !id) { - return; - } - - window.ga ||= function ga() { - (ga.q = ga.q || []).push(arguments); // eslint-disable-line - }; - ga.l = +new Date(); // eslint-disable-line - - loadScript(); - - window.ga("create", id, "auto"); - - if (set) { - for (const key of Object.keys(set)) { - window.ga("set", key, set[key]); - } - } -} - -function googleAnalyticsCommand(what, options, ...args) { - if (!window.ga) { - throw new Error("Google analytics is not initialized"); - } - - if (typeof options === "string") { - return window.ga(what, options, ...args); - } - - return window.ga(what, options); -} -function googleAnalyticsSet(...options) { - return googleAnalyticsCommand("set", ...options); -} -function googleAnalyticsSend(...options) { - return googleAnalyticsCommand("send", ...options); -} - -function GoogleAnalytics(props) { - const { id, set, children } = props; - - const location = useLocation(); - - useEffect(() => { - initGoogleAnalytics(id, set); - }, [id, set]); - - useEffect(() => { - const path = location.pathname + location.search; - - googleAnalyticsSet({ - page: path, - title: document.title, - location: document.location, - }); - - googleAnalyticsSend({ - hitType: "pageview", - }); - }, [location]); - - return <>{children}; -} - -GoogleAnalytics.propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - set: PropTypes.object, -}; diff --git a/src/App.jsx b/src/App.jsx index 26b692fbacd..21c70b11737 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ import Site from "./components/Site/Site.jsx"; export default function App() { - return import(`./content/${path}`)} />; + return import(`./content/${path}`)} />; } diff --git a/src/components/Badge/Badge.jsx b/src/components/Badge/Badge.jsx index 4f5128de5dd..01ccdad0438 100644 --- a/src/components/Badge/Badge.jsx +++ b/src/components/Badge/Badge.jsx @@ -1,8 +1,11 @@ import PropTypes from "prop-types"; -import "./Badge.scss"; export default function Badge(props) { - return {props.text}; + return ( + + {props.text} + + ); } Badge.propTypes = { diff --git a/src/components/Badge/Badge.scss b/src/components/Badge/Badge.scss deleted file mode 100644 index 57b1d52b5d8..00000000000 --- a/src/components/Badge/Badge.scss +++ /dev/null @@ -1,8 +0,0 @@ -.badge { - background-color: #1d78c1; - padding: 0 4px; - color: #fff; - position: relative; - top: -4px; - font-size: 14px; -} diff --git a/src/components/Badge/Badge.test.jsx b/src/components/Badge/Badge.test.jsx index 40a461e1f69..7ee309a33e2 100644 --- a/src/components/Badge/Badge.test.jsx +++ b/src/components/Badge/Badge.test.jsx @@ -1,11 +1,15 @@ +/** + * @jest-environment jsdom + */ // eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "@jest/globals"; -import renderer from "react-test-renderer"; + +import { render } from "@testing-library/react"; import Badge from "./Badge.jsx"; describe("Badge", () => { it("renders correctly with text prop", () => { - const tree = renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); }); }); diff --git a/src/components/Badge/__snapshots__/Badge.test.jsx.snap b/src/components/Badge/__snapshots__/Badge.test.jsx.snap index 9c8f80aa892..5484131a38c 100644 --- a/src/components/Badge/__snapshots__/Badge.test.jsx.snap +++ b/src/components/Badge/__snapshots__/Badge.test.jsx.snap @@ -2,7 +2,7 @@ exports[`Badge renders correctly with text prop 1`] = ` webpack diff --git a/src/components/CodeBlockWithCopy/CodeBlockWithCopy.jsx b/src/components/CodeBlockWithCopy/CodeBlockWithCopy.jsx index ca34c409b25..da0ddeb1695 100644 --- a/src/components/CodeBlockWithCopy/CodeBlockWithCopy.jsx +++ b/src/components/CodeBlockWithCopy/CodeBlockWithCopy.jsx @@ -1,10 +1,12 @@ import PropTypes from "prop-types"; -import { useRef, useState } from "react"; -import "./CodeBlockWithCopy.scss"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "../../utilities/cn.mjs"; export default function CodeBlockWithCopy({ children }) { const preRef = useRef(null); + const resetStatusTimeoutRef = useRef(null); const [copyStatus, setCopyStatus] = useState("copy"); + const codeClassName = typeof children?.props?.className === "string" ? children.props.className @@ -18,16 +20,40 @@ export default function CodeBlockWithCopy({ children }) { const clonedCodeElement = codeElement.cloneNode(true); - // Exclude +/-/unchanged tokens and removed lines + // Remove entire deleted lines for (const element of clonedCodeElement.querySelectorAll( - ".token.prefix.inserted, .token.prefix.unchanged, .token.deleted-sign.deleted", + ".token.deleted", )) { element.remove(); } + // Remove diff prefix tokens ('+', '-', ' ') — Prism renders these as separate spans + for (const element of clonedCodeElement.querySelectorAll(".token.prefix")) { + element.remove(); + } + + // Fallback: if no .token.prefix spans, strip leading '+' directly from inserted spans + for (const element of clonedCodeElement.querySelectorAll( + ".token.inserted", + )) { + if (element.textContent.startsWith("+")) { + element.textContent = element.textContent.slice(1); + } + } + return clonedCodeElement.textContent || ""; }; + const scheduleResetStatus = () => { + if (resetStatusTimeoutRef.current) { + clearTimeout(resetStatusTimeoutRef.current); + } + + resetStatusTimeoutRef.current = setTimeout(() => { + setCopyStatus("copy"); + }, 2000); + }; + const handleCopy = async () => { if (!preRef.current) return; @@ -38,13 +64,13 @@ export default function CodeBlockWithCopy({ children }) { if (!codeText) { setCopyStatus("error"); - setTimeout(() => setCopyStatus("copy"), 2000); + scheduleResetStatus(); return; } let successfulCopy = false; - // Try modern API (navigator.clipboard) -> as document.execCommand() deprecated + // Try modern API (navigator.clipboard) try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(codeText); @@ -76,14 +102,35 @@ export default function CodeBlockWithCopy({ children }) { } setCopyStatus(successfulCopy ? "copied" : "error"); - setTimeout(() => setCopyStatus("copy"), 2000); + scheduleResetStatus(); }; + useEffect( + () => () => { + if (resetStatusTimeoutRef.current) { + clearTimeout(resetStatusTimeoutRef.current); + } + }, + [], + ); + return ( -
+