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/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/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 be8e04af8c34..31175b9d58de 100644 --- a/package.json +++ b/package.json @@ -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", @@ -183,6 +183,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", @@ -251,6 +252,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 && } +