diff --git a/.gitignore b/.gitignore index 2426cd7bcd72..db0c3e8198af 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,6 @@ yarn-error.log* /server/*.js /server/*.js.map /ssr/dist/ -/ssr/*.js -!/ssr/mozilla.dnthelper.min.js -!/ssr/webpack.config.js /ssr/*.js.map /tool/*.js /tool/*.js.map diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a35289732c08..2c8b732d60a7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.63.0" + ".": "2.63.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d41d9cdad0f..7c91f97fb704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [2.63.1](https://github.com/mdn/yari/compare/v2.63.0...v2.63.1) (2024-09-16) + + +### Bug Fixes + +* **build:** use pipefail to fail function deployments ([#11806](https://github.com/mdn/yari/issues/11806)) ([5141019](https://github.com/mdn/yari/commit/5141019b54e24d8df6db8619dd1585c8fb1a7587)) +* **placement:** use variable for horizontal banner ([#11795](https://github.com/mdn/yari/issues/11795)) ([d880aaf](https://github.com/mdn/yari/commit/d880aaf8116f1fe692452f68107e173d042e5cb9)) +* **sidebar:** reuse icon margin for non-nested entries ([#11786](https://github.com/mdn/yari/issues/11786)) ([2294df0](https://github.com/mdn/yari/commit/2294df00ba8be26bf0767b78c02a0abad5c7894e)) + + +### Enhancements + +* **ad-free:** hide all banners completely ([#11787](https://github.com/mdn/yari/issues/11787)) ([f93b850](https://github.com/mdn/yari/commit/f93b85005b8f921e9f27266d02750af0363e1d06)) + + +### Miscellaneous + +* **deps-dev:** bump @playwright/test from 1.47.0 to 1.47.1 ([#11812](https://github.com/mdn/yari/issues/11812)) ([8e698db](https://github.com/mdn/yari/commit/8e698db7231a8ce0f87630bc50f19667b0deb136)) +* **deps-dev:** bump @swc/core from 1.7.24 to 1.7.25 ([#11791](https://github.com/mdn/yari/issues/11791)) ([0e30971](https://github.com/mdn/yari/commit/0e309719d5193d4dcc193cfd997dbd591475e590)) +* **deps-dev:** bump @swc/core from 1.7.25 to 1.7.26 ([#11793](https://github.com/mdn/yari/issues/11793)) ([51c07ba](https://github.com/mdn/yari/commit/51c07ba14af4d142b9ea16315a92fe65e35999c8)) +* **deps-dev:** bump @types/jest from 29.5.12 to 29.5.13 in the types group ([#11801](https://github.com/mdn/yari/issues/11801)) ([fc79d49](https://github.com/mdn/yari/commit/fc79d49564ba9bbb0fb7edff63c77947532e97d1)) +* **deps-dev:** bump @types/react from 18.3.5 to 18.3.6 in the types group ([#11808](https://github.com/mdn/yari/issues/11808)) ([9baf777](https://github.com/mdn/yari/commit/9baf777611cf9f3f7c0f65e5ece25d577290e856)) +* **deps-dev:** bump babel-loader from 9.1.3 to 9.2.0 ([#11811](https://github.com/mdn/yari/issues/11811)) ([fc35868](https://github.com/mdn/yari/commit/fc3586803901ea4d3a3fc66d0b6c18e57d025545)) +* **deps-dev:** bump eslint-plugin-react from 7.35.2 to 7.36.1 ([#11805](https://github.com/mdn/yari/issues/11805)) ([35fc136](https://github.com/mdn/yari/commit/35fc1362f82b1d6bea60d4a0225cf4e34d5fa6a1)) +* **deps-dev:** bump husky from 9.1.5 to 9.1.6 ([#11798](https://github.com/mdn/yari/issues/11798)) ([b3288e0](https://github.com/mdn/yari/commit/b3288e016c54fa41faac200e6a78be00f74509a0)) +* **deps-dev:** bump postcss from 8.4.45 to 8.4.47 ([#11810](https://github.com/mdn/yari/issues/11810)) ([2497484](https://github.com/mdn/yari/commit/2497484c5e94efaf9186940e28b84e3453459d05)) +* **deps-dev:** bump the types group across 1 directory with 2 updates ([#11743](https://github.com/mdn/yari/issues/11743)) ([a38975e](https://github.com/mdn/yari/commit/a38975ece5e7b4cf588964ea15b1abdb19b8c69b)) +* **deps:** bump express from 4.20.0 to 4.21.0 ([#11796](https://github.com/mdn/yari/issues/11796)) ([90e5231](https://github.com/mdn/yari/commit/90e5231100588a6a65073ff1946d05fff02ab33e)) +* **deps:** bump mdn-data from 2.11.0 to 2.11.1 ([#11802](https://github.com/mdn/yari/issues/11802)) ([16d9403](https://github.com/mdn/yari/commit/16d9403db82e6bb4cfa7e94e7fb36f42ce98e4c5)) +* **deps:** bump openai from 4.58.2 to 4.59.0 ([#11799](https://github.com/mdn/yari/issues/11799)) ([4877d5b](https://github.com/mdn/yari/commit/4877d5bf0dcd1c6baf6cbdeaa46f64d4e2c820ca)) +* **deps:** bump openai from 4.59.0 to 4.60.0 ([#11804](https://github.com/mdn/yari/issues/11804)) ([dd1c110](https://github.com/mdn/yari/commit/dd1c11065374834c0ed4dd48f20fc0a3db9d7657)) +* **deps:** bump openai from 4.60.0 to 4.61.0 ([#11809](https://github.com/mdn/yari/issues/11809)) ([bcc7727](https://github.com/mdn/yari/commit/bcc7727d0be0c3eb0abcbba2a256dd718d40b47f)) +* **deps:** bump the dependencies group in /deployer with 2 updates ([#11807](https://github.com/mdn/yari/issues/11807)) ([0caaee1](https://github.com/mdn/yari/commit/0caaee198887ff226a9d9d10bb48d1e5d5b6564b)) +* **deps:** bump web-features from 1.2.0 to 1.3.0 ([#11800](https://github.com/mdn/yari/issues/11800)) ([39e1574](https://github.com/mdn/yari/commit/39e1574b4a21e15bc287fcd5310755a7b2f7f015)) +* **deps:** run npm audit fix in /cloud-function ([#11792](https://github.com/mdn/yari/issues/11792)) ([43b307f](https://github.com/mdn/yari/commit/43b307fe9c59ee3e16eecb16be943a8dfaad6d7d)) +* **deps:** run yarn upgrade ([#11789](https://github.com/mdn/yari/issues/11789)) ([2777d58](https://github.com/mdn/yari/commit/2777d58faffad32ce25e479968306331cef3b001)) +* **macros:** delete DOMAttributeMethods + unimplemented_inline macros ([#11790](https://github.com/mdn/yari/issues/11790)) ([e0b7616](https://github.com/mdn/yari/commit/e0b76166d13511d773ee67c397a058eb883bb278)) +* **placement:** add scrimba discount ([#11785](https://github.com/mdn/yari/issues/11785)) ([ac157ff](https://github.com/mdn/yari/commit/ac157ff0a659e10b7fed6646d105e1ed90a7ea44)) +* **tools-menu:** remove "New" indicator ([#11794](https://github.com/mdn/yari/issues/11794)) ([f2bca97](https://github.com/mdn/yari/commit/f2bca97057fe51e69fb2ada1e1360767d798b531)) + ## [2.63.0](https://github.com/mdn/yari/compare/v2.62.0...v2.63.0) (2024-09-10) diff --git a/client/config/webpack.config.js b/client/config/webpack.config.js index db2139910913..f6245409aa1e 100644 --- a/client/config/webpack.config.js +++ b/client/config/webpack.config.js @@ -383,31 +383,16 @@ function config(webpackEnv) { }, plugins: [ // Generates an `index.html` file with the
diff --git a/client/scripts/build.js b/client/scripts/build.js index 3f1091c13e84..fac73d1b79fd 100644 --- a/client/scripts/build.js +++ b/client/scripts/build.js @@ -1,14 +1,12 @@ // Ensure environment variables are read. import "../config/env.js"; -import path from "node:path"; import chalk from "chalk"; import fs from "fs-extra"; import webpack from "webpack"; import configFactory from "../config/webpack.config.js"; import paths from "../config/paths.js"; -import { hashSomeStaticFilesForClientBuild } from "./postprocess-client-build.js"; // Makes the script crash on unhandled rejections instead of silently // ignoring them. In the future, promise rejections that are not handled will @@ -37,17 +35,6 @@ build() process.exit(1); } ) - .then(async () => { - const { results } = await hashSomeStaticFilesForClientBuild(paths.appBuild); - console.log( - chalk.green( - `Hashed ${results.length} files in ${path.join( - paths.appBuild, - "index.html" - )}` - ) - ); - }) .catch((err) => { if (err && err.message) { console.log(err.message); diff --git a/client/scripts/postprocess-client-build.js b/client/scripts/postprocess-client-build.js deleted file mode 100644 index 446f27d6420e..000000000000 --- a/client/scripts/postprocess-client-build.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * This script does all the necessary things the `yarn client:build` - * (react-scripts) can't do. - * - */ -import fs from "node:fs"; -import path from "node:path"; - -import cheerio from "cheerio"; -import md5File from "md5-file"; - -export async function hashSomeStaticFilesForClientBuild(buildRoot) { - const indexHtmlFilePath = path.join(buildRoot, "index.html"); - const indexHtml = fs.readFileSync(indexHtmlFilePath, "utf-8"); - - const results = []; - - // For every favicon referred there, change it to a file URL that - // has a hash in it. - const $ = cheerio.load(indexHtml); - $('link[rel], meta[property="og:image"]').each((i, element) => { - let href; - let attributeKey; - let hrefPrefix = ""; - if (element.tagName === "meta") { - if (element.attribs.property !== "og:image") { - return; - } - // This is a can of worms. Using from environment for now. - // We need to use an absolute URL for "og:image". - hrefPrefix = process.env.BASE_URL || ""; - href = element.attribs.content; - attributeKey = "content"; - } else { - href = element.attribs.href; - if (!href) { - return; - } - const rel = element.attribs.rel; - if ( - ![ - "icon", - "shortcut icon", - "apple-touch-icon", - "apple-touch-icon-precomposed", - "manifest", - ].includes(rel) - ) { - return; - } - attributeKey = "href"; - } - - // If this script is, for some reason, already run before we can - // bail if it looks like the href already is hashed. - if (/\.[a-f0-9]{8}\./.test(href)) { - console.warn(`Looks like ${href} is already hashed`); - return; - } - const filePath = hrefToFilePath(buildRoot, href); - if (!filePath || !fs.existsSync(filePath)) { - console.warn(`Unable to turn '${href}' into a valid file path`); - return; - } - // 8 because that's what react-scripts (which uses webpack somehow) - // uses to create those `build/static/**/*` files it builds. - const hash = md5File.sync(filePath).slice(0, 8); - const extName = path.extname(filePath); - const splitName = filePath.split(extName); - const hashedFilePath = `${splitName[0]}.${hash}${extName}`; - fs.copyFileSync(filePath, hashedFilePath); - const hashedHref = filePathToHref(buildRoot, hashedFilePath, href); - results.push({ - filePath, - href, - url: hrefPrefix + hashedHref, - hashedFilePath, - attributeKey, - }); - }); - - if (results.length > 0) { - // It clearly hashed some files. Let's update the HTML! - let newIndexHtml = indexHtml; - for (const { href, url, attributeKey } of results) { - newIndexHtml = newIndexHtml.replace( - new RegExp(`${attributeKey}="${href}"`), - `${attributeKey}="${url}"` - ); - } - fs.writeFileSync(indexHtmlFilePath, newIndexHtml, "utf-8"); - } - - return { results }; -} - -// Turn 'C:\Path\to\client\build\favicon.ico' to '/favicon.ico' -// or 'https://foo.bar/favicon.ico' if href is an absolute URL. -function filePathToHref(root, filePath, href) { - let dummyOrExistingUrl = new URL(href, "http://localhost.example"); - dummyOrExistingUrl.pathname = ""; - let url = new URL( - `${filePath.replace(root, "").replace(path.sep, "/")}`, - dummyOrExistingUrl - ); - if (url.hostname === "localhost.example") { - return url.pathname; - } else { - return url.href; - } -} - -// Turn '/favicon.ico' to 'C:\Path\to\client\build\favicon.ico' -function hrefToFilePath(root, href) { - // The href is always expected to start with a `/` which is part of a - // URL and not a file path. - const pathname = new URL(href, "http://localhost.example").pathname; - if (pathname.startsWith("/")) { - return path.join(root, pathname.slice(1).replace(/\//g, path.sep)); - } -} diff --git a/deployer/poetry.lock b/deployer/poetry.lock index 2f83222fb857..bde9d0101824 100644 --- a/deployer/poetry.lock +++ b/deployer/poetry.lock @@ -48,17 +48,17 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.35.14" +version = "1.35.19" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.14-py3-none-any.whl", hash = "sha256:c3e138e9041d59cd34cdc28a587dfdc899dba02ea26ebc3e10fb4bc88e5cf31b"}, - {file = "boto3-1.35.14.tar.gz", hash = "sha256:7bc78d7140c353b10a637927fe4bc4c4d95a464d1b8f515d5844def2ee52cbd5"}, + {file = "boto3-1.35.19-py3-none-any.whl", hash = "sha256:84b3fe1727945bc3cada832d969ddb3dc0d08fce1677064ca8bdc13a89c1a143"}, + {file = "boto3-1.35.19.tar.gz", hash = "sha256:9979fe674780a0b7100eae9156d74ee374cd1638a9f61c77277e3ce712f3e496"}, ] [package.dependencies] -botocore = ">=1.35.14,<1.36.0" +botocore = ">=1.35.19,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -67,13 +67,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.14" +version = "1.35.19" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.14-py3-none-any.whl", hash = "sha256:24823135232f88266b66ae8e1d0f3d40872c14cd976781f7fe52b8f0d79035a0"}, - {file = "botocore-1.35.14.tar.gz", hash = "sha256:8515a2fc7ca5bcf0b10016ba05ccf2d642b7cb77d8773026ff2fa5aa3bf38d2e"}, + {file = "botocore-1.35.19-py3-none-any.whl", hash = "sha256:c83f7f0cacfe7c19b109b363ebfa8736e570d24922f16ed371681f58ebab44a9"}, + {file = "botocore-1.35.19.tar.gz", hash = "sha256:42d6d8db7250cbd7899f786f9861e02cab17dc238f64d6acb976098ed9809625"}, ] [package.dependencies] @@ -82,7 +82,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.21.2)"] +crt = ["awscrt (==0.21.5)"] [[package]] name = "certifi" @@ -634,13 +634,13 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -931,4 +931,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "30854a8b32ec6583dd9579ef6bdde268a6969ed5e5d00671cb288a53a9f94df3" +content-hash = "c01b8f3c14a209010ec86754e0e1adde504fd8ce69065da03505cc4cc0e1f0fe" diff --git a/deployer/pyproject.toml b/deployer/pyproject.toml index 09a0d7c24f93..eefead0ecd9b 100644 --- a/deployer/pyproject.toml +++ b/deployer/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10" click = "^8.1.7" -boto3 = "^1.35.14" +boto3 = "^1.35.19" python-decouple = "^3.8" requests = {extras = ["security"], version = "^2.32.3"} elasticsearch-dsl = "^7.4.1" @@ -21,7 +21,7 @@ unidiff = "^0.7.5" [tool.poetry.dev-dependencies] black = "^24.8" flake8 = "^7.1.1" -pytest = "^8.3.2" +pytest = "^8.3.3" [tool.poetry.scripts] deployer = "deployer.main:cli" diff --git a/kumascript/tests/lib/__snapshots__/css-syntax.test.ts.snap b/kumascript/tests/lib/__snapshots__/css-syntax.test.ts.snap index f541c96c2c25..ef311f2859f1 100644 --- a/kumascript/tests/lib/__snapshots__/css-syntax.test.ts.snap +++ b/kumascript/tests/lib/__snapshots__/css-syntax.test.ts.snap @@ -4,7 +4,7 @@ exports[`CSSSyntax renders at-rule: @import 1`] = `"
src = 
<font-src-list>

"`; -exports[`CSSSyntax renders function: polygon 1`] = `"
<polygon()> = 
polygon( <'fill-rule'>? , [ <length-percentage> <length-percentage> ]# )

<fill-rule> =
nonzero |
evenodd

<length-percentage> =
<length> |
<percentage>

"`; +exports[`CSSSyntax renders function: polygon 1`] = `"
<polygon()> = 
polygon( <'fill-rule'>? [ round <length> ]? , [ <length-percentage> <length-percentage> ]# )

<fill-rule> =
nonzero |
evenodd

<length-percentage> =
<length> |
<percentage>

"`; exports[`CSSSyntax renders function: sin 1`] = `"
<sin()> = 
sin( <calc-sum> )

<calc-sum> =
<calc-product> [ [ '+' | '-' ] <calc-product> ]*

<calc-product> =
<calc-value> [ [ '*' | '/' ] <calc-value> ]*

<calc-value> =
<number> |
<dimension> |
<percentage> |
<calc-keyword> |
( <calc-sum> )

<calc-keyword> =
e |
pi |
infinity |
-infinity |
NaN

"`; diff --git a/libs/constants/index.js b/libs/constants/index.js index cf445f4149f2..a6fd9289b8ad 100644 --- a/libs/constants/index.js +++ b/libs/constants/index.js @@ -74,7 +74,7 @@ export const CSP_SCRIPT_SRC_VALUES = [ "https://js.stripe.com", /* - * Inline scripts (defined in `client/public/index.html`). + * Inline scripts (imported in `ssr/render.tsx`). * * If we modify them, we must always update their CSP hash here. * diff --git a/package.json b/package.json index edbee377d902..49093261accb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mdn/yari", - "version": "2.63.0", + "version": "2.63.1", "repository": "https://github.com/mdn/yari", "license": "MPL-2.0", "author": "MDN Web Docs", @@ -25,7 +25,7 @@ "build:docs": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node build/cli.ts -n", "build:glean": "cd client && cross-env VIRTUAL_ENV=venv glean translate src/telemetry/metrics.yaml src/telemetry/pings.yaml -f typescript -o src/telemetry/generated", "build:prepare": "yarn build:client && yarn build:ssr && yarn tool popularities && yarn tool spas && yarn tool gather-git-history && yarn tool build-robots-txt", - "build:ssr": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node ssr/prepare.ts && cd ssr && webpack --mode=production", + "build:ssr": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node ssr/prepare.ts && webpack --mode=production --config=ssr/webpack.config.js", "build:sw": "cd client/pwa && yarn && yarn build:prod", "build:sw-dev": "cd client/pwa && yarn && yarn build", "check:tsc": "find . -name 'tsconfig.json' ! -wholename '**/node_modules/**' -print0 | xargs -n1 -P 2 -0 sh -c 'cd `dirname $0` && echo \"🔄 $(pwd)\" && npx tsc --noEmit && echo \"☑️ $(pwd)\" || exit 255'", @@ -41,7 +41,7 @@ "prettier-check": "prettier --check .", "prettier-format": "prettier --write .", "render:html": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node build/ssr-cli.ts", - "start": "(test -f client/build/index.html || yarn build:client) && (test -f ssr/dist/main.js || yarn build:ssr) && (test -f popularities.json || yarn tool popularities) && (test -d client/build/en-us/_spas || yarn tool spas) && nf -j Procfile.start start", + "start": "(test -f client/build/asset-manifest.json || yarn build:client) && (test -f ssr/dist/main.js || yarn build:ssr) && (test -f popularities.json || yarn tool popularities) && (test -d client/build/en-us/_spas || yarn tool spas) && nf -j Procfile.start start", "start:client": "cd client && cross-env NODE_ENV=development BABEL_ENV=development PORT=3000 node scripts/start.js", "start:server": "node-dev --experimental-loader ts-node/esm server/index.ts", "start:static-server": "cross-env NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node server/static.ts", @@ -56,7 +56,7 @@ "test:prepare": "yarn build:prepare && yarn build:docs && yarn render:html && yarn start:static-server", "test:testing": "yarn jest --rootDir testing", "tool": "cross-env NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node ./tool/cli.ts", - "watch:ssr": "cd ssr && webpack --mode=production --watch" + "watch:ssr": "webpack --mode=production --watch --config=ssr/webpack.config.js" }, "resolutions": { "http-cache-semantics": ">=4.1.1", @@ -78,7 +78,7 @@ "@stripe/stripe-js": "^4.4.0", "@use-it/interval": "^1.0.0", "@vscode/ripgrep": "^1.15.9", - "@webref/css": "^6.15.1", + "@webref/css": "^6.15.2", "accept-language-parser": "^1.5.0", "async": "^3.2.6", "chalk": "^5.3.0", @@ -120,7 +120,7 @@ "mdn-data": "^2.11.1", "open": "^10.1.0", "open-editor": "^5.0.0", - "openai": "^4.60.0", + "openai": "^4.61.0", "pg": "^8.12.0", "pgvector": "^0.2.0", "prism-svelte": "^0.5.0", @@ -156,7 +156,7 @@ "@babel/preset-env": "^7.25.4", "@mdn/dinocons": "^0.5.5", "@mdn/minimalist": "^2.0.4", - "@playwright/test": "^1.47.0", + "@playwright/test": "^1.47.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.7.26", @@ -169,11 +169,11 @@ "@types/mdast": "^4.0.4", "@types/node": "^18.19.50", "@types/prismjs": "^1.26.4", - "@types/react": "^18.3.5", + "@types/react": "^18.3.6", "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", "babel-jest": "^29.7.0", - "babel-loader": "^9.1.3", + "babel-loader": "^9.2.0", "babel-plugin-named-asset-import": "^0.3.8", "babel-preset-react-app": "^10.0.1", "bfj": "^8.0.0", @@ -184,6 +184,7 @@ "cross-env": "^7.0.3", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", + "cssnano": "^7.0.6", "diff": "^7.0.0", "downshift": "^7.6.1", "eslint": "^8.57.0", @@ -219,7 +220,7 @@ "mini-css-extract-plugin": "^2.9.1", "node-dev": "^8.0.0", "peggy": "^4.0.3", - "postcss": "^8.4.45", + "postcss": "^8.4.47", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^8.1.1", "postcss-normalize": "^13.0.0", @@ -252,6 +253,7 @@ "stylelint-prettier": "^4.1.0", "stylelint-scss": "^5.3.2", "swr": "^2.2.5", + "terser-loader": "^2.0.3", "terser-webpack-plugin": "^5.3.10", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", diff --git a/ssr/include.d.ts b/ssr/include.d.ts index b7b76f87e0c2..894812f5d06d 100644 --- a/ssr/include.d.ts +++ b/ssr/include.d.ts @@ -1,4 +1,10 @@ -export const WEBFONT_TAGS: string; +interface AssetManifest { + files: Record; + entrypoints: string[]; +} + +export const WEBFONT_URLS: string[]; export const GTAG_PATH: null | string; export const BASE_URL: string; export const ALWAYS_ALLOW_ROBOTS: boolean; +export const ASSET_MANIFEST: AssetManifest; diff --git a/ssr/prepare.ts b/ssr/prepare.ts index fce23157a5fa..b9d05334538c 100644 --- a/ssr/prepare.ts +++ b/ssr/prepare.ts @@ -37,15 +37,6 @@ function* extractCSSURLs(css, filterFunction) { } } -function webfontTags(webfontURLs): string { - return webfontURLs - .map( - (url) => - `` - ) - .join(""); -} - function gtagScriptPath(relPath = "/static/js/gtag.js") { const filePath = relPath.split("/").slice(1).join(path.sep); if (fs.existsSync(path.join(BUILD_OUT_ROOT, filePath))) { @@ -56,16 +47,19 @@ function gtagScriptPath(relPath = "/static/js/gtag.js") { function prepare() { const webfontURLs = extractWebFontURLs(); - const tags = webfontTags(webfontURLs); const gtagPath = gtagScriptPath(); + const assetManifest = JSON.parse( + fs.readFileSync(path.join(clientBuildRoot, "asset-manifest.json"), "utf-8") + ); fs.writeFileSync( path.join(dirname, "ssr", "include.ts"), ` -export const WEBFONT_TAGS = ${JSON.stringify(tags)}; +export const WEBFONT_URLS = ${JSON.stringify(webfontURLs)}; export const GTAG_PATH = ${JSON.stringify(gtagPath)}; export const BASE_URL = ${JSON.stringify(BASE_URL)}; export const ALWAYS_ALLOW_ROBOTS = ${JSON.stringify(ALWAYS_ALLOW_ROBOTS)}; +export const ASSET_MANIFEST = ${JSON.stringify(assetManifest)}; ` ); } diff --git a/ssr/print.css b/ssr/print.css new file mode 100644 index 000000000000..de4edbf69eac --- /dev/null +++ b/ssr/print.css @@ -0,0 +1,22 @@ +.article-actions-container, +.main-menu-toggle, +.document-toc-container, +.on-github, +.sidebar, +.top-navigation-main, +.page-footer, +.top-banner, +.place, +ul.prev-next, +.language-menu { + display: none !important; +} + +.main-page-content, +.main-page-content pre { + padding: 2px; +} + +.main-page-content pre { + border-left-width: 2px; +} diff --git a/ssr/react-app.d.ts b/ssr/react-app.d.ts index 8206136fee8c..8930f7c00dc1 100644 --- a/ssr/react-app.d.ts +++ b/ssr/react-app.d.ts @@ -89,3 +89,13 @@ declare module "*.module.sass" { const classes: { readonly [key: string]: string }; export default classes; } + +declare module "*?inline" { + const source: string; + export default source; +} + +declare module "*?public" { + const src: string; + export default src; +} diff --git a/ssr/render.ts b/ssr/render.ts deleted file mode 100644 index f3fcb41ff11c..000000000000 --- a/ssr/render.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { renderToString } from "react-dom/server"; -import { HydrationData } from "../libs/types/hydration"; - -import { DEFAULT_LOCALE } from "../libs/constants/index"; -import { - ALWAYS_ALLOW_ROBOTS, - BASE_URL, - WEBFONT_TAGS, - GTAG_PATH, -} from "./include"; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import HTML from "../client/build/index.html?raw"; -import { getMetaDescription } from "./meta-description"; - -// When there are multiple options for a given language, this gives the -// preferred locale for that language (language => preferred locale). -const PREFERRED_LOCALE = { - pt: "pt-PT", - zh: "zh-CN", -}; - -// We should use the language tag (e.g. "zh-Hans") instead of the locale. -// This is a map of locale => language tag. -// See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry -const LANGUAGE_TAGS = Object.freeze({ - "zh-CN": "zh-Hans", - "zh-TW": "zh-Hant", -}); - -function htmlEscape(s: string) { - if (!s) { - return s; - } - return s - .replace(/&/gim, "&") - .replace(/"/gim, """) - .replace(//gim, ">") - .replace(/'/gim, "'"); -} - -function getHrefLang(locale: string, allLocales: Array) { - // In most cases, just return the language code, removing the country - // code if present (so, for example, 'en-US' becomes 'en'). - const hreflang = locale.split("-")[0]; - - // Suppose the locale is one that is ambiguous, we need to fall back on a - // a preferred one. For example, if the document is available in 'zh-CN' and - // in 'zh-TW', we need to output something like this: - // - // - // - // But other bother if both ambigious locale-to-hreflang are present. - const preferred = PREFERRED_LOCALE[hreflang]; - if (preferred) { - // e.g. `preferred===zh-CN` if hreflang was `zh` - if (locale !== preferred) { - // e.g. `locale===zh-TW` - if (allLocales.includes(preferred)) { - // If the more preferred one was there, use the locale + region format. - return LANGUAGE_TAGS[locale] ?? locale; - } - } - } - return hreflang; -} - -const lazy = (creator) => { - let res; - let processed = false; - return (...args) => { - if (processed) return res; - res = creator.apply(this, ...args); - processed = true; - return res; - }; -}; - -// Path strings are preferred over URLs here to mitigate Webpack resolution - -const readBuildHTML = lazy(() => { - if (!HTML.includes('
')) { - throw new Error( - 'The render depends on being able to inject into
' - ); - } - const scripts: string[] = []; - const gaScriptPathName = GTAG_PATH; - if (gaScriptPathName) { - scripts.push(``); - } - - const html = HTML.replace('', () => - scripts.join("") - ); - return html; -}); - -export default function render( - renderApp, - url: string, - { - doc = null, - pageNotFound = false, - hyData = null, - pageTitle = null, - pageDescription = "", - possibleLocales = null, - locale = null, - noIndexing = false, - onlyFollow = false, - image = null, - blogMeta = null, - }: HydrationData = { url } -) { - const buildHtml = readBuildHTML(); - const rendered = renderToString(renderApp); - - const canonicalURL = `${BASE_URL}${url}`; - - let escapedPageTitle = htmlEscape(pageTitle); - let metaDescription = pageDescription; - - const hydrationData: HydrationData = { url }; - const translations: string[] = []; - if (blogMeta) { - hydrationData.blogMeta = blogMeta; - } - if (pageNotFound) { - escapedPageTitle = `🤷🏽‍♀️ Page not found | ${ - escapedPageTitle || "MDN Web Docs" - }`; - hydrationData.pageNotFound = true; - } else if (hyData) { - hydrationData.hyData = hyData; - } else if (doc) { - // Use the doc's title instead - escapedPageTitle = htmlEscape(doc.pageTitle); - - metaDescription = htmlEscape(getMetaDescription(doc)); - if (doc.summary) { - pageDescription = htmlEscape(doc.summary); - } - - hydrationData.doc = doc; - - if (doc.other_translations) { - // Note, we also always include "self" as a locale. That's why we concat - // this doc's locale plus doc.other_translations. - const thisLocale = { - locale: doc.locale, - title: doc.title, - url: doc.mdn_url, - }; - - const allTranslations = [...doc.other_translations, thisLocale]; - const allLocales = allTranslations.map((t) => t.locale); - - for (const translation of allTranslations) { - const translationURL = doc.mdn_url.replace( - `/${doc.locale}/`, - () => `/${translation.locale}/` - ); - // The locale used in `` needs to be the ISO-639-1 - // code. For example, it's "en", not "en-US". And it's "sv" not "sv-SE". - // See https://developers.google.com/search/docs/specialty/international/localized-versions#language-codes - translations.push( - `` - ); - } - } - } - - if (possibleLocales) { - hydrationData.possibleLocales = possibleLocales; - } - - const titleTag = `${escapedPageTitle || "MDN Web Docs"}`; - - // Open Graph protocol expects `language_TERRITORY` format. - const ogLocale = (locale || (doc && doc.locale) || DEFAULT_LOCALE).replace( - "-", - "_" - ); - - const og = new Map([ - ["title", escapedPageTitle], - ["url", canonicalURL], - ["locale", ogLocale], - ]); - - if (pageDescription) { - og.set("description", pageDescription); - } - - if (image) { - og.set("image", image); - } - - const root = `
${rendered}
`; - - const robotsContent = - !ALWAYS_ALLOW_ROBOTS || (doc && doc.noIndexing) || noIndexing - ? "noindex, nofollow" - : onlyFollow - ? "noindex, follow" - : ""; - const robotsMeta = robotsContent - ? `` - : ""; - const rssLink = ``; - const ssr_data = [...translations, ...WEBFONT_TAGS, rssLink, robotsMeta]; - let html = buildHtml; - html = html.replace( - ' `/g, - (_, typ, content) => { - return ``; - } - ); - if (metaDescription) { - html = html.replace(//g, () => { - return ``; - }); - } - html = html.replace("MDN Web Docs", () => `${titleTag}`); - - html = html.replace( - '', - () => (pageNotFound ? "" : ``) - ); - - html = html.replace('', () => ssr_data.join("")); - html = html.replace('
', () => root); - return html; -} diff --git a/ssr/render.tsx b/ssr/render.tsx new file mode 100644 index 000000000000..cd8164e256fd --- /dev/null +++ b/ssr/render.tsx @@ -0,0 +1,271 @@ +import { renderToString } from "react-dom/server"; +import { HydrationData } from "../libs/types/hydration"; + +import { DEFAULT_LOCALE } from "../libs/constants/index"; +import { + ALWAYS_ALLOW_ROBOTS, + BASE_URL, + WEBFONT_URLS, + GTAG_PATH, + ASSET_MANIFEST, +} from "./include"; +import { getMetaDescription } from "./meta-description"; + +import favicon from "../client/public/favicon-48x48.png?public"; +import appleIcon from "../client/public/apple-touch-icon.png?public"; +import manifest from "../client/public/manifest.json?public"; +import ogImage from "../client/public/mdn-social-share.png?public"; +import printCSS from "./print.css?inline"; +import themeJS from "./theme.js?inline"; + +// When there are multiple options for a given language, this gives the +// preferred locale for that language (language => preferred locale). +const PREFERRED_LOCALE = { + pt: "pt-PT", + zh: "zh-CN", +}; + +// We should use the language tag (e.g. "zh-Hans") instead of the locale. +// This is a map of locale => language tag. +// See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry +const LANGUAGE_TAGS = Object.freeze({ + "zh-CN": "zh-Hans", + "zh-TW": "zh-Hant", +}); + +function getHrefLang(locale: string, allLocales: Array) { + // In most cases, just return the language code, removing the country + // code if present (so, for example, 'en-US' becomes 'en'). + const hreflang = locale.split("-")[0]; + + // Suppose the locale is one that is ambiguous, we need to fall back on a + // a preferred one. For example, if the document is available in 'zh-CN' and + // in 'zh-TW', we need to output something like this: + // + // + // + // But other bother if both ambigious locale-to-hreflang are present. + const preferred = PREFERRED_LOCALE[hreflang]; + if (preferred) { + // e.g. `preferred===zh-CN` if hreflang was `zh` + if (locale !== preferred) { + // e.g. `locale===zh-TW` + if (allLocales.includes(preferred)) { + // If the more preferred one was there, use the locale + region format. + return LANGUAGE_TAGS[locale] ?? locale; + } + } + } + return hreflang; +} + +export default function render( + renderApp, + url: string, + { + doc = null, + pageNotFound = false, + hyData = null, + pageTitle = null, + pageDescription = "", + possibleLocales = null, + locale = null, + noIndexing = false, + onlyFollow = false, + image = null, + blogMeta = null, + }: HydrationData = { url } +) { + const canonicalURL = `${BASE_URL}${url}`; + + let realPageTitle = pageTitle; + let metaDescription = pageDescription; + + const hydrationData: HydrationData = { url }; + const translations: JSX.Element[] = []; + if (blogMeta) { + hydrationData.blogMeta = blogMeta; + } + if (pageNotFound) { + realPageTitle = `🤷🏽‍♀️ Page not found | ${realPageTitle || "MDN Web Docs"}`; + hydrationData.pageNotFound = true; + } else if (hyData) { + hydrationData.hyData = hyData; + } else if (doc) { + // Use the doc's title instead + realPageTitle = doc.pageTitle; + + metaDescription = getMetaDescription(doc); + if (doc.summary) { + pageDescription = doc.summary; + } + + hydrationData.doc = doc; + + if (doc.other_translations) { + // Note, we also always include "self" as a locale. That's why we concat + // this doc's locale plus doc.other_translations. + const thisLocale = { + locale: doc.locale, + title: doc.title, + url: doc.mdn_url, + }; + + const allTranslations = [...doc.other_translations, thisLocale]; + const allLocales = allTranslations.map((t) => t.locale); + + for (const translation of allTranslations) { + const translationURL = doc.mdn_url.replace( + `/${doc.locale}/`, + () => `/${translation.locale}/` + ); + // The locale used in `` needs to be the ISO-639-1 + // code. For example, it's "en", not "en-US". And it's "sv" not "sv-SE". + // See https://developers.google.com/search/docs/specialty/international/localized-versions#language-codes + translations.push( + + ); + } + } + } + + if (possibleLocales) { + hydrationData.possibleLocales = possibleLocales; + } + + // Open Graph protocol expects `language_TERRITORY` format. + const ogLocale = (locale || (doc && doc.locale) || DEFAULT_LOCALE).replace( + "-", + "_" + ); + + const robotsContent = + !ALWAYS_ALLOW_ROBOTS || (doc && doc.noIndexing) || noIndexing + ? "noindex, nofollow" + : onlyFollow + ? "noindex, follow" + : ""; + + return ( + "" + + renderToString( + + + + + + + + + + + + + + + + {realPageTitle || "MDN Web Docs"} + {translations} + {WEBFONT_URLS.map((url) => ( + + ))} + + {robotsContent && } + + + + + + + + + + + + + + + + + {!pageNotFound && } +