From aa2ebecc3e843e7f5df512208584b5182398c5d0 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 2 Jan 2026 17:14:34 -0500 Subject: [PATCH 1/2] feat(mdx): move to `next-mdx-remote` --- apps/site/app/[locale]/[...path]/page.tsx | 99 +--- .../site/app/[locale]/blog/[...path]/page.tsx | 82 ---- apps/site/app/[locale]/blog/[cat]/page.tsx | 53 +++ .../download/archive/[version]/page.tsx | 46 +- apps/site/app/[locale]/page.tsx | 91 ++-- apps/site/app/sitemap.ts | 10 +- apps/site/mdx/compiler.mjs | 75 --- apps/site/mdx/plugins.mjs | 56 --- apps/site/next-env.d.ts | 2 +- apps/site/next.dynamic.mjs | 274 ----------- apps/site/next.dynamic.page.mjs | 127 ----- apps/site/next.helpers.mjs | 42 +- apps/site/package.json | 9 +- .../constants.ts} | 25 +- apps/site/router/index.ts | 182 ++++++++ apps/site/{ => router}/mdx/components.mjs | 2 +- apps/site/router/mdx/plugins.ts | 31 ++ apps/site/router/mdx/plugins/headings.mjs | 20 + apps/site/router/mdx/plugins/shiki.mjs | 23 + apps/site/router/mdx/plugins/table.mjs | 44 ++ apps/site/router/page.ts | 45 ++ apps/site/router/render.tsx | 21 + .../blog-data/__test__/generate.test.mjs | 5 +- apps/site/scripts/blog-data/generate.mjs | 12 +- .../scripts/orama-search/get-documents.mjs | 18 +- apps/site/types/index.ts | 2 +- .../types/{frontmatter.ts => markdown.ts} | 7 + apps/site/types/server.ts | 12 +- apps/site/util/__tests__/table.test.mjs | 434 ------------------ apps/site/util/array.ts | 7 + apps/site/util/table.ts | 48 -- pnpm-lock.yaml | 100 ++-- 32 files changed, 587 insertions(+), 1417 deletions(-) delete mode 100644 apps/site/app/[locale]/blog/[...path]/page.tsx create mode 100644 apps/site/app/[locale]/blog/[cat]/page.tsx delete mode 100644 apps/site/mdx/compiler.mjs delete mode 100644 apps/site/mdx/plugins.mjs delete mode 100644 apps/site/next.dynamic.mjs delete mode 100644 apps/site/next.dynamic.page.mjs rename apps/site/{next.dynamic.constants.mjs => router/constants.ts} (81%) create mode 100644 apps/site/router/index.ts rename apps/site/{ => router}/mdx/components.mjs (98%) create mode 100644 apps/site/router/mdx/plugins.ts create mode 100644 apps/site/router/mdx/plugins/headings.mjs create mode 100644 apps/site/router/mdx/plugins/shiki.mjs create mode 100644 apps/site/router/mdx/plugins/table.mjs create mode 100644 apps/site/router/page.ts create mode 100644 apps/site/router/render.tsx rename apps/site/types/{frontmatter.ts => markdown.ts} (63%) delete mode 100644 apps/site/util/__tests__/table.test.mjs delete mode 100644 apps/site/util/table.ts diff --git a/apps/site/app/[locale]/[...path]/page.tsx b/apps/site/app/[locale]/[...path]/page.tsx index aecb38f3da63f..390f4ef65860c 100644 --- a/apps/site/app/[locale]/[...path]/page.tsx +++ b/apps/site/app/[locale]/[...path]/page.tsx @@ -1,97 +1,2 @@ -/** - * This file extends on the `page.tsx` file, which is the default file that is used to render - * the entry points for each locale and then also reused within the [...path] route to render the - * and contains all logic for rendering our dynamic and static routes within the Node.js Website. - * - * Note: that each `page.tsx` should have its own `generateStaticParams` to prevent clash of - * dynamic params, which will lead on static export errors and other sort of issues. - */ - -import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; -import { notFound } from 'next/navigation'; - -import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; -import { ENABLE_STATIC_EXPORT_LOCALE } from '#site/next.constants.mjs'; -import { dynamicRouter } from '#site/next.dynamic.mjs'; -import * as basePage from '#site/next.dynamic.page.mjs'; - -import type { DynamicParams } from '#site/types'; -import type { FC } from 'react'; - -type PageParams = DynamicParams<{ path: Array }>; - -// This is the default Viewport Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function -export const generateViewport = basePage.generateViewport; - -// This generates each page's HTML Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata -export const generateMetadata = basePage.generateMetadata; - -// Generates all possible static paths based on the locales and environment configuration -// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) -// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales -// - Otherwise, generates paths only for the default locale -// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params -export const generateStaticParams = async () => { - // Return an empty array if static export is disabled - if (!ENABLE_STATIC_EXPORT) { - return []; - } - - const routes = await dynamicRouter.getAllRoutes(); - - // Helper function to fetch and map routes for a specific locale - const getRoutesForLocale = async (l: string) => - routes.map(pathname => dynamicRouter.mapPathToRoute(l, pathname)); - - // Determine which locales to include in the static export - const locales = ENABLE_STATIC_EXPORT_LOCALE - ? availableLocaleCodes - : [defaultLocale.code]; - - // Generates all possible routes for all available locales - const routesWithLocales = await Promise.all(locales.map(getRoutesForLocale)); - - return routesWithLocales.flat().sort(); -}; - -// This method parses the current pathname and does any sort of modifications needed on the route -// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component -// finally it returns (if the locale and route are valid) the React Component with the relevant context -// and attached context providers for rendering the current page -const getPage: FC = async props => { - const { path, locale: routeLocale } = await props.params; - - // Gets the current full pathname for a given path - const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); - - // Gets the Markdown content and context - const [content, context] = await basePage.getMarkdownContext({ - locale, - pathname, - }); - - // If we have a filename and layout then we have a page - if (context.filename && context.frontmatter.layout) { - return basePage.renderPage({ - content, - layout: context.frontmatter.layout, - context, - }); - } - - return notFound(); -}; - -// Enforces that this route is used as static rendering -// Except whenever on the Development mode as we want instant-refresh when making changes -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'force-static'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = 300; - -export default getPage; +export * from '../page'; +export { default } from '../page'; diff --git a/apps/site/app/[locale]/blog/[...path]/page.tsx b/apps/site/app/[locale]/blog/[...path]/page.tsx deleted file mode 100644 index 2a11e65becc46..0000000000000 --- a/apps/site/app/[locale]/blog/[...path]/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { defaultLocale } from '@node-core/website-i18n'; -import { notFound } from 'next/navigation'; - -import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; -import { BLOG_DYNAMIC_ROUTES } from '#site/next.dynamic.constants.mjs'; -import * as basePage from '#site/next.dynamic.page.mjs'; - -import type { DynamicParams } from '#site/types'; -import type { FC } from 'react'; - -type PageParams = DynamicParams<{ path: Array }>; - -// This is the default Viewport Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function -export const generateViewport = basePage.generateViewport; - -// This generates each page's HTML Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata -export const generateMetadata = ({ params }: PageParams) => - basePage.generateMetadata({ params, prefix: 'blog' }); - -// Generates all possible static paths based on the locales and environment configuration -// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) -// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales -// - Otherwise, generates paths only for the default locale -// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params -export const generateStaticParams = async () => { - // Return an empty array if static export is disabled - if (!ENABLE_STATIC_EXPORT) { - return []; - } - - return BLOG_DYNAMIC_ROUTES.map(pathname => ({ - locale: defaultLocale.code, - path: pathname.split('/'), - })); -}; - -// This method parses the current pathname and does any sort of modifications needed on the route -// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component -// finally it returns (if the locale and route are valid) the React Component with the relevant context -// and attached context providers for rendering the current page -const getPage: FC = async props => { - const { path, locale: routeLocale } = await props.params; - - // Gets the current full pathname for a given path - const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); - - // Verifies if the current route is a dynamic route - const isDynamicRoute = BLOG_DYNAMIC_ROUTES.some(r => r.includes(pathname)); - - // Gets the Markdown content and context for Blog pages - // otherwise this is likely a blog-category or a blog post - const [content, context] = await basePage.getMarkdownContext({ - locale, - pathname: `blog/${pathname}`, - }); - - // If this isn't a valid dynamic route for blog post or there's no markdown file - // for this, then we fail as not found as there's nothing we can do. - if (isDynamicRoute || context.filename) { - return basePage.renderPage({ - content, - layout: context.frontmatter.layout ?? 'blog-category', - context: { ...context, pathname: `/blog/${pathname}` }, - }); - } - - return notFound(); -}; - -// Enforces that this route is used as static rendering -// Except whenever on the Development mode as we want instant-refresh when making changes -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'force-static'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = 300; - -export default getPage; diff --git a/apps/site/app/[locale]/blog/[cat]/page.tsx b/apps/site/app/[locale]/blog/[cat]/page.tsx new file mode 100644 index 0000000000000..3fdbefc807b26 --- /dev/null +++ b/apps/site/app/[locale]/blog/[cat]/page.tsx @@ -0,0 +1,53 @@ +import { defaultLocale } from '@node-core/website-i18n'; +import { notFound } from 'next/navigation'; + +import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; +import { blogData } from '#site/next.json.mjs'; +import { getMarkdownFile } from '#site/router'; +import { BLOG_DYNAMIC_ROUTES } from '#site/router/constants'; +import { renderPage } from '#site/router/render'; + +import type { DynamicParams } from '#site/types'; +import type { FC } from 'react'; + +type PageParams = DynamicParams<{ cat: string }>; + +// Generates all possible static paths based on the locales and environment configuration +// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) +// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales +// - Otherwise, generates paths only for the default locale +// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params +export const generateStaticParams = async () => { + // Return an empty array if static export is disabled + if (!ENABLE_STATIC_EXPORT) { + return []; + } + + return blogData.categories.map(cat => ({ + locale: defaultLocale.code, + cat, + })); +}; + +// This method parses the current pathname and does any sort of modifications needed on the route +// then it proceeds to retrieve the Markdown file and parse the MDX Content into a React Component +// finally it returns (if the locale and route are valid) the React Component with the relevant context +// and attached context providers for rendering the current page +const getPage: FC = async props => { + const { cat, locale } = await props.params; + + // Verifies if the current route is a dynamic route + const isDynamicRoute = BLOG_DYNAMIC_ROUTES.some(r => r.includes(cat)); + + if (isDynamicRoute) { + const file = (await getMarkdownFile(locale, 'blog'))!; + file.pathname = `/blog/${cat}`; + + return renderPage(file); + } + + return notFound(); +}; + +export * from '../../page'; +export default getPage; diff --git a/apps/site/app/[locale]/download/archive/[version]/page.tsx b/apps/site/app/[locale]/download/archive/[version]/page.tsx index 1cc23837ccdf1..ab38064e4969b 100644 --- a/apps/site/app/[locale]/download/archive/[version]/page.tsx +++ b/apps/site/app/[locale]/download/archive/[version]/page.tsx @@ -4,21 +4,14 @@ import { notFound, redirect } from 'next/navigation'; import provideReleaseData from '#site/next-data/providers/releaseData'; import provideReleaseVersions from '#site/next-data/providers/releaseVersions'; import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; -import * as basePage from '#site/next.dynamic.page.mjs'; +import { getMarkdownFile } from '#site/router'; +import { renderPage } from '#site/router/render'; import type { DynamicParams } from '#site/types'; import type { FC } from 'react'; type PageParams = DynamicParams<{ version: string }>; -// This is the default Viewport Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function -export const generateViewport = basePage.generateViewport; - -// This generates each page's HTML Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata -export const generateMetadata = basePage.generateMetadata; - // Generates all possible static paths based on the locales and environment configuration // - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) // - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales @@ -43,10 +36,7 @@ export const generateStaticParams = async () => { // finally it returns (if the locale and route are valid) the React Component with the relevant context // and attached context providers for rendering the current page const getPage: FC = async props => { - const { version, locale: routeLocale } = await props.params; - - // Gets the current full pathname for a given path - const [locale, pathname] = basePage.getLocaleAndPath(version, routeLocale); + const { version, locale } = await props.params; if (version === 'current') { const releaseData = await provideReleaseData(); @@ -59,35 +49,19 @@ const getPage: FC = async props => { const versions = await provideReleaseVersions(); // Verifies if the current route is a dynamic route - const isDynamicRoute = versions.some(r => r.includes(pathname)); - - // Gets the Markdown content and context for Download Archive pages - const [content, context] = await basePage.getMarkdownContext({ - locale, - pathname: 'download/archive', - }); + const isDynamicRoute = versions.some(r => r.includes(version)); // If this isn't a valid dynamic route for archive version or there's no markdown // file for this, then we fail as not found as there's nothing we can do. - if (isDynamicRoute && context.filename) { - return basePage.renderPage({ - content, - layout: context.frontmatter.layout!, - context: { ...context, pathname: `/download/archive/${pathname}` }, - }); + if (isDynamicRoute) { + const markdown = (await getMarkdownFile(locale, 'download/archive'))!; + markdown.pathname = `/download/archive/${version}`; + + return renderPage(markdown); } return notFound(); }; -// Enforces that this route is used as static rendering -// Except whenever on the Development mode as we want instant-refresh when making changes -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'force-static'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = 300; - export default getPage; +export * from '#site/router/page'; diff --git a/apps/site/app/[locale]/page.tsx b/apps/site/app/[locale]/page.tsx index 711e785a9a8fb..be7e61f7b19c6 100644 --- a/apps/site/app/[locale]/page.tsx +++ b/apps/site/app/[locale]/page.tsx @@ -1,31 +1,33 @@ -import { defaultLocale, availableLocaleCodes } from '@node-core/website-i18n'; -import { notFound } from 'next/navigation'; +/** + * This file extends on the `page.tsx` file, which is the default file that is used to render + * the entry points for each locale and then also reused within the [...path] route to render the + * and contains all logic for rendering our dynamic and static routes within the Node.js Website. + * + * Note: that each `page.tsx` should have its own `generateStaticParams` to prevent clash of + * dynamic params, which will lead on static export errors and other sort of issues. + */ -import { ENABLE_STATIC_EXPORT } from '#site/next.constants.mjs'; -import { ENABLE_STATIC_EXPORT_LOCALE } from '#site/next.constants.mjs'; -import * as basePage from '#site/next.dynamic.page.mjs'; +import { sep } from 'node:path'; -import type { DynamicParams } from '#site/types'; -import type { FC } from 'react'; - -type PageParams = DynamicParams<{ path: Array }>; +import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; +import { notFound } from 'next/navigation'; -// This is the default Viewport Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function -export const generateViewport = basePage.generateViewport; +import { + ENABLE_STATIC_EXPORT, + ENABLE_STATIC_EXPORT_LOCALE, +} from '#site/next.constants.mjs'; +import { allRoutes, getMarkdownFile } from '#site/router'; +import { renderPage } from '#site/router/render'; +import { joinNested } from '#site/util/array'; -// This generates each page's HTML Metadata -// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata -export const generateMetadata = basePage.generateMetadata; +import type { PageParams } from '#site/router/page'; +import type { FC } from 'react'; -/** - * Generates all possible static paths based on the locales and environment configuration - * - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) - * - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales - * - Otherwise, generates paths only for the default locale - * - * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params - */ +// Generates all possible static paths based on the locales and environment configuration +// - Returns an empty array if static export is disabled (`ENABLE_STATIC_EXPORT` is false) +// - If `ENABLE_STATIC_EXPORT_LOCALE` is true, generates paths for all available locales +// - Otherwise, generates paths only for the default locale +// @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params export const generateStaticParams = async () => { // Return an empty array if static export is disabled if (!ENABLE_STATIC_EXPORT) { @@ -37,12 +39,12 @@ export const generateStaticParams = async () => { ? availableLocaleCodes : [defaultLocale.code]; - const routes = await Promise.all( - // Gets all mapped routes to the Next.js Routing Engine by Locale - locales.map(locale => ({ locale })) + return locales.map((locale: string) => + allRoutes.map(path => ({ + locale, + path: path.split(sep), + })) ); - - return routes.flat().sort(); }; // This method parses the current pathname and does any sort of modifications needed on the route @@ -50,37 +52,12 @@ export const generateStaticParams = async () => { // finally it returns (if the locale and route are valid) the React Component with the relevant context // and attached context providers for rendering the current page const getPage: FC = async props => { - const { path, locale: routeLocale } = await props.params; - - // Gets the current full pathname for a given path - const [locale, pathname] = basePage.getLocaleAndPath(path, routeLocale); + const { path = [], locale } = await props.params; - // Gets the Markdown content and context - const [content, context] = await basePage.getMarkdownContext({ - locale, - pathname, - }); + const markdown = await getMarkdownFile(locale, joinNested(path)); - // If we have a filename and layout then we have a page - if (context.filename && context.frontmatter.layout) { - return basePage.renderPage({ - content, - layout: context.frontmatter.layout, - context, - }); - } - - return notFound(); + return markdown ? renderPage(markdown) : notFound(); }; -// Enforces that this route is used as static rendering -// Except whenever on the Development mode as we want instant-refresh when making changes -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -export const dynamic = 'force-static'; - -// Ensures that this endpoint is invalidated and re-executed every X minutes -// so that when new deployments happen, the data is refreshed -// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate -export const revalidate = 300; - export default getPage; +export * from '#site/router/page'; diff --git a/apps/site/app/sitemap.ts b/apps/site/app/sitemap.ts index 68de1f7ceb13e..2a18465a636d8 100644 --- a/apps/site/app/sitemap.ts +++ b/apps/site/app/sitemap.ts @@ -3,11 +3,12 @@ import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; import { BASE_PATH } from '#site/next.constants.mjs'; import { BASE_URL } from '#site/next.constants.mjs'; import { EXTERNAL_LINKS_SITEMAP } from '#site/next.constants.mjs'; -import { BLOG_DYNAMIC_ROUTES } from '#site/next.dynamic.constants.mjs'; -import { dynamicRouter } from '#site/next.dynamic.mjs'; +import { BLOG_DYNAMIC_ROUTES } from '#site/router/constants.js'; import type { MetadataRoute } from 'next'; +import { allRoutes } from '../router'; + // This is the combination of the Application Base URL and Base PATH const baseUrlAndPath = `${BASE_URL}${BASE_PATH}`; @@ -21,9 +22,6 @@ const getAlternatePath = (r: string, locales: Array) => // This allows us to generate a `sitemap.xml` file dynamically based on the needs of the Node.js Website const sitemap = async (): Promise => { - // Gets a list of all statically available routes - const routes = await dynamicRouter.getAllRoutes(); - const currentDate = new Date().toISOString(); const getSitemapEntry = (r: string, locales: Array = []) => ({ @@ -33,7 +31,7 @@ const sitemap = async (): Promise => { alternates: { languages: getAlternatePath(r, locales) }, }); - const staticPaths = routes.map(r => getSitemapEntry(r, nonDefaultLocales)); + const staticPaths = allRoutes.map(r => getSitemapEntry(r, nonDefaultLocales)); const blogPaths = BLOG_DYNAMIC_ROUTES.map(r => getSitemapEntry(`blog/${r}`)); const externalPaths = EXTERNAL_LINKS_SITEMAP.map(r => getSitemapEntry(r)); diff --git a/apps/site/mdx/compiler.mjs b/apps/site/mdx/compiler.mjs deleted file mode 100644 index 630ba814eb73d..0000000000000 --- a/apps/site/mdx/compiler.mjs +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -import { compile as mdxCompile } from '@mdx-js/mdx'; -import { Fragment, jsx, jsxs } from 'react/jsx-runtime'; -import { matter } from 'vfile-matter'; - -import { rehypePlugins, remarkPlugins } from './plugins.mjs'; -import { createGitHubSlugger } from '../util/github'; -import createInterpreter from '../util/interpreter'; - -// Defines a JSX Fragment and JSX Runtime for the MDX Compiler -const reactRuntime = { Fragment, jsx, jsxs }; - -/** - * This is our custom simple MDX Compiler that is used to compile Markdown and MDX - * this returns a serializable VFile as a string that then gets passed to our MDX Provider - * - * @param {import('vfile').VFile} source The source Markdown/MDX content - * @param {'md' | 'mdx'} fileExtension If it should use the MDX or a plain Markdown parser/compiler - * @param {import('mdx/types').MDXComponents} components The MDX Components to be used in the MDX Provider - * @param {Record} props Extra optional React props for the MDX Provider - * - * @returns {Promise<{ - * content: import('react').ReactElement; - * headings: Array; - * frontmatter: Record; - * readingTime: import('reading-time').ReadTimeResults; - * }>} - */ -export default async function compile( - source, - fileExtension, - components = {}, - props = {} -) { - // Parses the Frontmatter to the VFile and removes from the original source - // cleaning the frontmatter to the source that is going to be parsed by the MDX Compiler - matter(source, { strip: true }); - - // Creates a GitHub slugger to generate the same slugs as GitHub - const slugger = createGitHubSlugger(); - - // Compiles the MDX/Markdown source into a serializable VFile - const compiled = await mdxCompile(source, { - rehypePlugins, - remarkPlugins, - format: fileExtension, - }); - - const interpreter = createInterpreter({ - ...components, - 'react/jsx-runtime': reactRuntime, - }); - - // Run the compiled JavaScript code from MDX - interpreter.run(compiled.toString()); - - // Retrieve the default export from the compiled MDX - const MDXContent = interpreter.exports.default; - - // Render the MDX content directly from the compiler - const content = ; - - // Retrieve some parsed data from the VFile metadata - // such as frontmatter and Markdown headings - const { headings = [], matter: frontmatter, readingTime } = source.data; - - headings.forEach(heading => { - // we re-sluggify the links to match the GitHub slugger - // since some also do not come with sluggifed links - heading.data = { ...heading.data, id: slugger(heading.value) }; - }); - - return { content, headings, frontmatter, readingTime }; -} diff --git a/apps/site/mdx/plugins.mjs b/apps/site/mdx/plugins.mjs deleted file mode 100644 index 02166643aaf26..0000000000000 --- a/apps/site/mdx/plugins.mjs +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -import rehypeShikiji from '@node-core/rehype-shiki/plugin'; -import remarkHeadings from '@vcarl/remark-headings'; -import rehypeAutolinkHeadings from 'rehype-autolink-headings'; -import rehypeSlug from 'rehype-slug'; -import remarkGfm from 'remark-gfm'; -import readingTime from 'remark-reading-time'; - -import remarkTableTitles from '../util/table'; - -// TODO(@avivkeller): When available, use `OPEN_NEXT_CLOUDFLARE` environment -// variable for detection instead of current method, which will enable better -// tree-shaking. -// Reference: https://github.com/nodejs/nodejs.org/pull/7896#issuecomment-3009480615 -const OPEN_NEXT_CLOUDFLARE = 'Cloudflare' in global; - -// Shiki is created out here to avoid an async rehype plugin -const singletonShiki = await rehypeShikiji({ - // We use the faster WASM engine on the server instead of the web-optimized version. - // - // Currently we fall back to the JavaScript RegEx engine - // on Cloudflare workers because `shiki/wasm` requires loading via - // `WebAssembly.instantiate` with custom imports, which Cloudflare doesn't support - // for security reasons. - wasm: !OPEN_NEXT_CLOUDFLARE, - - // TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare - twoslash: !OPEN_NEXT_CLOUDFLARE, -}); - -/** - * Provides all our Rehype Plugins that are used within MDX - */ -export const rehypePlugins = [ - // Generates `id` attributes for headings (H1, ...) - rehypeSlug, - // Automatically add anchor links to headings (H1, ...) - [rehypeAutolinkHeadings, { behavior: 'wrap' }], - // Transforms sequential code elements into code tabs and - // adds our syntax highlighter (Shikiji) to Codeboxes - () => singletonShiki, -]; - -/** - * Provides all our Remark Plugins that are used within MDX - */ -export const remarkPlugins = [ - // Support GFM syntax to be used within Markdown - remarkGfm, - // Generates metadata regarding headings - remarkHeadings, - // Calculates the reading time of the content - readingTime, - remarkTableTitles, -]; diff --git a/apps/site/next-env.d.ts b/apps/site/next-env.d.ts index c05d9f7d66f17..cdb6b7b848c32 100644 --- a/apps/site/next-env.d.ts +++ b/apps/site/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/site/next.dynamic.mjs b/apps/site/next.dynamic.mjs deleted file mode 100644 index 6dfea0d82f56a..0000000000000 --- a/apps/site/next.dynamic.mjs +++ /dev/null @@ -1,274 +0,0 @@ -'use strict'; - -import { readFile } from 'node:fs/promises'; -import { join, normalize, sep } from 'node:path'; - -import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; -import matter from 'gray-matter'; -import { cache } from 'react'; -import { VFile } from 'vfile'; - -import compile from './mdx/compiler.mjs'; -import mdxComponents from './mdx/components.mjs'; -import { BASE_PATH } from './next.constants.mjs'; -import { BASE_URL } from './next.constants.mjs'; -import { DEFAULT_CATEGORY_OG_TYPE } from './next.constants.mjs'; -import { ENABLE_STATIC_EXPORT } from './next.constants.mjs'; -import { IS_DEV_ENV } from './next.constants.mjs'; -import { PAGE_METADATA } from './next.dynamic.constants.mjs'; -import { getMarkdownFiles } from './next.helpers.mjs'; -import { siteConfig } from './next.json.mjs'; - -// This is the combination of the Application Base URL and Base PATH -const baseUrlAndPath = `${BASE_URL}${BASE_PATH}`; - -// This is a small utility that allows us to quickly separate locale from the remaining pathname -const getPathname = (path = []) => - Array.isArray(path) ? path.join('/') : path; - -// This maps a pathname into an actual route object that can be used -// we use a platform-specific separator to split the pathname -// since we're using filepaths here and not URL paths -const mapPathToRoute = (locale = defaultLocale.code, path = '') => ({ - locale, - path: path.split(sep), -}); - -// Provides an in-memory Map that lasts the whole build process -// and disabled when on development mode (stubbed) -const createCachedMarkdownCache = () => { - if (IS_DEV_ENV) { - return { - has: () => false, - set: () => {}, - get: () => null, - }; - } - - return new Map(); -}; - -const getDynamicRouter = async () => { - // Creates a Cache System that is disabled during development mode - const cachedMarkdownFiles = createCachedMarkdownCache(); - - // Keeps the map of pathnames to filenames - const pathnameToFilename = new Map(); - - const websitePages = await getMarkdownFiles( - process.cwd(), - `pages/${defaultLocale.code}` - ); - - websitePages.forEach(filename => { - // This Regular Expression is used to remove the `index.md(x)` suffix - // of a name and to remove the `.md(x)` extensions of a filename. - let pathname = filename.replace(/((\/)?(index))?\.mdx?$/i, ''); - - if (pathname.length > 1 && pathname.endsWith(sep)) { - pathname = pathname.substring(0, pathname.length - 1); - } - - pathname = normalize(pathname).replace('.', ''); - - // We map the pathname to the filename to be able to quickly - // resolve the filename for a given pathname - pathnameToFilename.set(pathname, filename); - }); - - /** - * This method returns a list of all routes that exist - * Note: It will only match routes that have at least one pathname. - - * @returns {Promise>} - */ - const getAllRoutes = async () => - [...pathnameToFilename.keys()].filter(pathname => pathname.length); - - /** - * This method attempts to retrieve either a localized Markdown file - * or the English version of the Markdown file if no localized version exists - * and then returns the contents of the file and the name of the file (not the path) - * - * @param {string} locale - * @param {string} pathname - * @returns {Promise<{ source: string; filename: string }>} - */ - const _getMarkdownFile = async (locale = '', pathname = '') => { - const normalizedPathname = normalize(pathname).replace('.', ''); - - // This verifies if the given pathname actually exists on our Map - // meaning that the route exists on the website and can be rendered - if (pathnameToFilename.has(normalizedPathname)) { - const filename = pathnameToFilename.get(normalizedPathname); - const filepath = join(process.cwd(), 'pages', locale, filename); - - // We verify if our Markdown cache already has a cache entry for a localized - // version of this file, because if not, it means that either - // we did not cache this file yet or there is no localized version of this file - if (cachedMarkdownFiles.has(`${locale}${normalizedPathname}`)) { - const fileContent = cachedMarkdownFiles.get( - `${locale}${normalizedPathname}` - ); - - return { source: fileContent, filename }; - } - - // Attempts to read a file or simply (and silently) fail, as the file might - // simply not exist or whatever other reason that might cause the file to not be read - const fileLanguageContent = await readFile(filepath, 'utf8').catch( - () => undefined - ); - - // No cache hit exists, so we check if the localized file actually - // exists within our file system and if it does we set it on the cache - // and return the current fetched result; - if (fileLanguageContent && typeof fileLanguageContent === 'string') { - cachedMarkdownFiles.set( - `${locale}${normalizedPathname}`, - fileLanguageContent - ); - - return { source: fileLanguageContent, filename }; - } - - // Prevent infinite loops as if at this point the file does not exist with the default locale - // then there must be an issue on the file system or there's an error on the mapping of paths to files - if (locale === defaultLocale.code) { - return { filename: '', source: '' }; - } - - // We attempt to retrieve the source version (defaultLocale) of the file as there is no localised version - // of the file and we set it on the cache to prevent future checks of the same locale for this file - const { source: fileContent } = await _getMarkdownFile( - defaultLocale.code, - pathname - ); - - // We set the source file on the localized cache to prevent future checks - // of the same locale for this file and improve read performance - cachedMarkdownFiles.set(`${locale}${normalizedPathname}`, fileContent); - - return { source: fileContent, filename }; - } - - return { filename: '', source: '' }; - }; - - // Creates a Cached Version of the Markdown File Resolver - const getMarkdownFile = cache(async (locale, pathname) => { - return await _getMarkdownFile(locale, pathname); - }); - - /** - * This method runs the MDX compiler on the server-side and returns the - * parsed JSX ready to be rendered on a page as a React Component - * - * @param {string} source - * @param {string} filename - */ - const _getMDXContent = async (source = '', filename = '') => { - // We create a VFile (Virtual File) to be able to access some contextual - // data post serialization (compilation) of the source Markdown into MDX - const sourceAsVirtualFile = new VFile(source); - - // Gets the file extension of the file, to determine which parser and plugins to use - const fileExtension = filename.endsWith('.mdx') ? 'mdx' : 'md'; - - // This compiles our MDX source (VFile) into a final MDX-parsed VFile - // that then is passed as a string to the MDXProvider which will run the MDX Code - return compile(sourceAsVirtualFile, fileExtension, mdxComponents); - }; - - // Creates a Cached Version of the MDX Compiler - const getMDXContent = cache(async (source, filename) => { - return await _getMDXContent(source, filename); - }); - - /** - * This method generates the Next.js App Router Metadata - * that can be used for each page to provide metadata - * - * @param {string} locale - * @param {string} path - * @returns {Promise} - */ - const _getPageMetadata = async (locale = defaultLocale.code, path = '') => { - const pageMetadata = { ...PAGE_METADATA }; - - const { source = '' } = await getMarkdownFile(locale, path); - - const { data } = matter(source); - - const getUrlForPathname = (l, p) => - `${baseUrlAndPath}/${l}${p ? `/${p}` : ''}`; - - // Default Title for the page - pageMetadata.title = data.title - ? `${siteConfig.title} — ${data.title}` - : siteConfig.title; - - pageMetadata.description = data.description - ? data.description - : siteConfig.description; - - // Default Twitter Title for the page - pageMetadata.twitter.title = pageMetadata.title; - - // Default Open Graph Image for the page - pageMetadata.openGraph.images = [ - ENABLE_STATIC_EXPORT - ? `${defaultLocale.code}/next-data/og/announcement/Run JavaScript Everywhere` - : `${defaultLocale.code}/next-data/og/${data.category ?? DEFAULT_CATEGORY_OG_TYPE}/${pageMetadata.title}`, - ]; - - // Default canonical URL for the page - pageMetadata.alternates.canonical = - data.canonical ?? getUrlForPathname(locale, path); - - // Default alternate URL for the page in the default locale - pageMetadata.alternates.languages['x-default'] = getUrlForPathname( - defaultLocale.code, - path - ); - - // Retrieves a matching blog feed for the category of the blog post - // If no matching blog feed is found, we simply fallback to the default blog feed - const matchingBlogFeed = siteConfig.rssFeeds.find( - feed => feed.category === data.category - ); - - // Adds the RSS Feed URL to the page metadata, if a matching feed is found - // otherwise, we fallback to the default blog feed - pageMetadata.alternates.types['application/rss+xml'] = getUrlForPathname( - locale, - `feed/${matchingBlogFeed?.file ?? 'blog.xml'}` - ); - - // Iterate all languages to generate alternate URLs for each language - availableLocaleCodes.forEach(currentLocale => { - pageMetadata.alternates.languages[currentLocale] = getUrlForPathname( - currentLocale, - path - ); - }); - - return pageMetadata; - }; - - // Creates a Cached Version of the Page Metadata Context - const getPageMetadata = cache(async (locale, path) => { - return await _getPageMetadata(locale, path); - }); - - return { - mapPathToRoute, - getPathname, - getAllRoutes, - getMDXContent, - getMarkdownFile, - getPageMetadata, - }; -}; - -export const dynamicRouter = await getDynamicRouter(); diff --git a/apps/site/next.dynamic.page.mjs b/apps/site/next.dynamic.page.mjs deleted file mode 100644 index 08cd8f6118c3f..0000000000000 --- a/apps/site/next.dynamic.page.mjs +++ /dev/null @@ -1,127 +0,0 @@ -import { join } from 'node:path'; - -import { - allLocaleCodes, - defaultLocale, - availableLocaleCodes, -} from '@node-core/website-i18n'; -import { notFound, redirect } from 'next/navigation'; -import { setRequestLocale } from 'next-intl/server'; - -import { setClientContext } from '#site/client-context'; -import WithLayout from '#site/components/withLayout'; -import { PAGE_VIEWPORT } from '#site/next.dynamic.constants.mjs'; -import { dynamicRouter } from '#site/next.dynamic.mjs'; -import { MatterProvider } from '#site/providers/matterProvider'; - -/** - * This is the default Viewport Metadata - * - * @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function - * - * @returns {import('next').Viewport} the default viewport metadata - */ -export const generateViewport = () => ({ ...PAGE_VIEWPORT }); - -/** - * This generates each page's HTML Metadata - * - * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata - * - * @param {{ params: Promise<{ path: Array; locale: string }>, prefix?: string }} props - * @returns {Promise} the metadata for the page - */ -export const generateMetadata = async ({ params, prefix }) => { - const { path = [], locale = defaultLocale.code } = await params; - - const pathname = dynamicRouter.getPathname(path); - - return dynamicRouter.getPageMetadata( - locale, - // If there's a prefix, `join` it with the pathname - prefix ? join(prefix, pathname) : pathname - ); -}; - -/** - * This method is used for retrieving the current locale and pathname from the request - * - * @param {string|Array} path - * @param {string} locale - * @returns {[string, string]} the locale and pathname for the request - */ -export const getLocaleAndPath = (path = [], locale = defaultLocale.code) => { - if (!availableLocaleCodes.includes(locale)) { - // Forces the current locale to be the Default Locale - setRequestLocale(defaultLocale.code); - - if (!allLocaleCodes.includes(locale)) { - // when the locale is not listed in the locales, return NotFound - return notFound(); - } - - // Redirect to the default locale path - const pathname = dynamicRouter.getPathname(path); - - return redirect(`/${defaultLocale.code}/${pathname}`); - } - - // Configures the current Locale to be the given Locale of the Request - setRequestLocale(locale); - - // Gets the current full pathname for a given path - return [locale, dynamicRouter.getPathname(path)]; -}; - -/** - * This method is used for retrieving the Markdown content and context - * - * @param {{ locale: string; pathname: string }} props - * @returns {Promise<[import('react').ReactNode, import('#site/types/server').ClientSharedServerContext]>} - */ -export const getMarkdownContext = async props => { - // We retrieve the source of the Markdown file by doing an educated guess - // of what possible files could be the source of the page, since the extension - // context is lost from `getStaticProps` as a limitation of Next.js itself - const { source, filename } = await dynamicRouter.getMarkdownFile( - props.locale, - props.pathname - ); - - // This parses the source Markdown content and returns a React Component and - // relevant context from the Markdown File - const { content, frontmatter, headings, readingTime } = - await dynamicRouter.getMDXContent(source, filename); - - // Metadata and shared Context to be available through the lifecycle of the page - const context = { - frontmatter, - headings, - pathname: `/${props.pathname}`, - readingTime, - filename, - }; - - return [content, context]; -}; - -/** - * This method is used for rendering the actual page - * - * @param {{ content: import('react').ReactNode; layout: import('#site/types/layouts').Layouts; context: Partial; }} props - * @returns {import('react').ReactElement} - */ -export const renderPage = props => { - // Defines a shared Server Context for the Client-Side - // That is shared for all pages under the dynamic router - setClientContext(props.context); - - // The Matter Provider allows Client-Side injection of the data - // to a shared React Client Provider even though the page is rendered - // within a server-side context - return ( - - {props.content} - - ); -}; diff --git a/apps/site/next.helpers.mjs b/apps/site/next.helpers.mjs index 9d71e012fa840..ad8bfcfd2f861 100644 --- a/apps/site/next.helpers.mjs +++ b/apps/site/next.helpers.mjs @@ -1,24 +1,11 @@ 'use strict'; import { glob } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; -/** - * We create a locale cache of Glob Promises - * to avoid reading the file system multiple times - * this is done since we don't need to constantly re-run the glob - * query as it is only needed once - * - * @type {Map>} */ -const globCacheByPath = new Map(); +import { defaultLocale } from '@node-core/website-i18n/index.mjs'; -/** - * This gets the relative path from `import.meta.url` - * - * @param {string} path the current import path - * @returns {string} the relative path from import - */ -export const getRelativePath = path => fileURLToPath(new URL('.', path)); +export const CONTENT_ROOT = join(process.cwd(), 'pages'); /** * This method is responsible for retrieving a glob of all files that exist @@ -27,21 +14,14 @@ export const getRelativePath = path => fileURLToPath(new URL('.', path)); * Note that we ignore the blog directory for static builds as otherwise generating * that many pages would be too much for the build process to handle. * - * @param {string} root the root directory to search from - * @param {string} cwd the given locale code - * @param {Array} exclude an array of glob patterns to ignore + * @param {import('node:fs').GlobOptions} options * @returns {Promise>} a promise containing an array of paths */ -export const getMarkdownFiles = async (root, cwd, exclude = []) => { - const cacheKey = `${root}${cwd}${exclude.join('')}`; - - if (!globCacheByPath.has(cacheKey)) { - const result = Array.fromAsync( - glob('**/*.{md,mdx}', { root, cwd, exclude }) - ); - - globCacheByPath.set(cacheKey, result); - } - - return globCacheByPath.get(cacheKey); +export const getMarkdownFiles = async (options = {}) => { + return Array.fromAsync( + glob('**/*.{md,mdx}', { + cwd: join(CONTENT_ROOT, defaultLocale.code), + ...options, + }) + ); }; diff --git a/apps/site/package.json b/apps/site/package.json index f0339f01b5ba4..8328da7b99988 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -33,7 +33,6 @@ }, "dependencies": { "@heroicons/react": "~2.2.0", - "@mdx-js/mdx": "^3.1.1", "@node-core/rehype-shiki": "workspace:*", "@node-core/ui-components": "workspace:*", "@node-core/website-i18n": "workspace:*", @@ -49,7 +48,6 @@ "@tailwindcss/postcss": "~4.1.18", "@types/node": "catalog:", "@types/react": "catalog:", - "@vcarl/remark-headings": "~0.1.0", "@vercel/analytics": "~1.5.0", "@vercel/otel": "~2.1.0", "@vercel/speed-insights": "~1.2.0", @@ -61,6 +59,7 @@ "mdast-util-to-string": "^4.0.0", "next": "16.0.10", "next-intl": "~4.5.3", + "next-mdx-remote": "^5.0.0", "next-themes": "~0.4.6", "postcss-calc": "~10.1.1", "react": "catalog:", @@ -69,17 +68,15 @@ "rehype-autolink-headings": "~7.1.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.1", - "remark-reading-time": "~2.0.2", "semver": "~7.7.3", "sval": "^0.6.8", "tailwindcss": "catalog:", "twoslash": "^0.3.6", - "unist-util-visit": "^5.0.0", - "vfile": "~6.0.3", - "vfile-matter": "~5.0.1" + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@flarelabs-net/wrangler-build-time-fs-assets-polyfilling": "^0.0.1", + "@mdx-js/mdx": "^3.1.1", "@next/eslint-plugin-next": "16.0.7", "@node-core/remark-lint": "workspace:*", "@opennextjs/cloudflare": "^1.14.7", diff --git a/apps/site/next.dynamic.constants.mjs b/apps/site/router/constants.ts similarity index 81% rename from apps/site/next.dynamic.constants.mjs rename to apps/site/router/constants.ts index 0aa3afbbb071e..efe96577d23eb 100644 --- a/apps/site/next.dynamic.constants.mjs +++ b/apps/site/router/constants.ts @@ -2,16 +2,19 @@ import { blogData } from '#site/next.json.mjs'; -import { BASE_PATH, BASE_URL } from './next.constants.mjs'; -import { siteConfig } from './next.json.mjs'; -import { getBlogPosts } from './util/blog'; +import { BASE_PATH, BASE_URL } from '../next.constants.mjs'; +import { siteConfig } from '../next.json.mjs'; +import { getBlogPosts } from '../util/blog'; + +/** + * This the pattern of index.mdx? files in a given directory + */ +export const INDEX_PATTERN = /((\/)?(index))?\.mdx?$/i; /** * This constant is used to create static routes on-the-fly that do not have a file-system * counterpart route. This is useful for providing routes with matching Layout Names * but that do not have Markdown content and a matching file for the route - * - * @type {Array} A Map of pathname and Layout Name */ export const BLOG_DYNAMIC_ROUTES = [ // Provides Routes for all Blog Categories @@ -25,12 +28,10 @@ export const BLOG_DYNAMIC_ROUTES = [ .map(([c, t]) => [...Array(t).keys()].map(p => `${c}/page/${p + 1}`)) // flattens the array since we have a .map inside another .map .flat(), -]; +] as const; /** * This is the default Next.js Page Metadata for all pages - * - * @type {import('next').Metadata} */ export const PAGE_METADATA = { metadataBase: new URL(`${BASE_URL}${BASE_PATH}`), @@ -39,7 +40,7 @@ export const PAGE_METADATA = { robots: { index: true, follow: true }, twitter: { card: siteConfig.twitter.card, - title: siteConfig.twitter.title, + title: siteConfig.title, creator: siteConfig.twitter.username, images: { url: siteConfig.twitter.img, @@ -48,13 +49,13 @@ export const PAGE_METADATA = { }, alternates: { canonical: '', - languages: { 'x-default': '' }, + languages: { 'x-default': '' } as Record, types: { 'application/rss+xml': `${BASE_URL}${BASE_PATH}/en/feed/blog.xml`, }, }, icons: { icon: siteConfig.favicon }, - openGraph: { images: siteConfig.twitter.img }, + openGraph: { images: [siteConfig.twitter.img] }, }; /** @@ -76,4 +77,4 @@ export const PAGE_VIEWPORT = { width: 'device-width', initialScale: 1, maximumScale: 2, -}; +} as const; diff --git a/apps/site/router/index.ts b/apps/site/router/index.ts new file mode 100644 index 0000000000000..7f74af608c143 --- /dev/null +++ b/apps/site/router/index.ts @@ -0,0 +1,182 @@ +import { readFile } from 'node:fs/promises'; +import { join, normalize, sep } from 'node:path'; + +import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; +import { compileMDX } from 'next-mdx-remote/rsc'; +import { cache } from 'react'; +import readingTime from 'reading-time'; + +import { + BASE_PATH, + BASE_URL, + DEFAULT_CATEGORY_OG_TYPE, + ENABLE_STATIC_EXPORT, +} from '#site/next.constants.mjs'; +import { CONTENT_ROOT, getMarkdownFiles } from '#site/next.helpers.mjs'; +import { siteConfig } from '#site/next.json.mjs'; + +import type { MarkdownFile } from '../types'; + +import { PAGE_METADATA, INDEX_PATTERN } from './constants'; +import components from './mdx/components.mjs'; +import getPlugins from './mdx/plugins'; + +const pathnameToFilename = new Map(); + +/** + * Normalize any filesystem path into a stable, URL-safe form. + */ +const normalizePath = (path: string): string => + normalize(path).replace(/\\/g, '/').replace(/^\./, ''); + +/** + * Convert a filename into a route pathname + */ +const normalizePathname = (filename: string): string => { + let pathname = filename.replace(INDEX_PATTERN, ''); + + if (pathname.length > 1 && pathname.endsWith(sep)) { + pathname = pathname.slice(0, -1); + } + + return pathname; +}; + +/** + * Attempt to load and compile a markdown file for a given locale + pathname. + * Returns `null` if the file does not exist + */ +const tryLoadMarkdown = async (locale: string, pathname: string) => { + const normalizedPath = normalizePath(pathname); + const filename = pathnameToFilename.get(normalizedPath); + + if (!filename) { + return null; + } + + const filePath = join(CONTENT_ROOT, locale, filename); + + const source = await readFile(filePath, 'utf-8'); + + const metadata = { + readingTime: readingTime(source), + }; + + const compiled = await compileMDX({ + source, + components, + options: { + parseFrontmatter: true, + mdxOptions: getPlugins(metadata), + }, + }); + + return { + pathname: `/${normalizedPath}`, + filename, + ...metadata, + ...compiled, + } as MarkdownFile; +}; + +/** + * Load markdown with locale fallback. + * Tries requested locale first, then default locale. + */ +export const getMarkdownFile = cache( + async (locale: string, pathname: string) => { + return ( + (await tryLoadMarkdown(locale, pathname)) ?? + (locale !== defaultLocale.code + ? tryLoadMarkdown(defaultLocale.code, pathname) + : null) + ); + } +); + +/** + * Construct a fully-qualified URL for a locale + pathname. + */ +const getUrlForPathname = (locale: string, pathname: string) => { + const normalizedPath = normalizePath(pathname); + const suffix = normalizedPath ? `/${normalizedPath}` : ''; + return `${BASE_URL}${BASE_PATH}/${locale}${suffix}`; +}; + +/** + * Generate alternate-language URLs for SEO metadata. + */ +const buildAlternateLanguages = (path: string): Record => { + const alternates: Record = { + 'x-default': getUrlForPathname(defaultLocale.code, path), + }; + + for (const locale of availableLocaleCodes) { + alternates[locale] = getUrlForPathname(locale, path); + } + + return alternates; +}; + +/** + * Generate full metadata object for a page + */ +export const getPageMetadata = cache(async (locale: string, path: string) => { + const markdown = await getMarkdownFile(locale, path); + const metadata = Object.assign({}, PAGE_METADATA); + + if (!markdown) { + return metadata; + } + + const { frontmatter } = markdown; + + /* Title */ + metadata.title = frontmatter.title + ? `${siteConfig.title} — ${frontmatter.title}` + : siteConfig.title; + + /* Description */ + metadata.description = frontmatter.description ?? siteConfig.description; + + /* Twitter */ + metadata.twitter.title = metadata.title; + + /* Open Graph Image */ + metadata.openGraph.images = [ + ENABLE_STATIC_EXPORT + ? `${defaultLocale.code}/next-data/og/announcement/Run JavaScript Everywhere` + : `${defaultLocale.code}/next-data/og/${ + frontmatter.category ?? DEFAULT_CATEGORY_OG_TYPE + }/${metadata.title}`, + ]; + + /* Canonical & alternates */ + metadata.alternates.canonical = + frontmatter.canonical ?? getUrlForPathname(locale, path); + + metadata.alternates.languages = buildAlternateLanguages(path); + + /* RSS Feed */ + const feed = + siteConfig.rssFeeds.find(f => f.category === frontmatter.category)?.file ?? + 'blog.xml'; + + metadata.alternates.types['application/rss+xml'] = getUrlForPathname( + locale, + `feed/${feed}` + ); + + return metadata; +}); + +export const allRoutes = (await getMarkdownFiles()) + .map(file => { + const normalizedFile = normalizePath(file); + const pathname = normalizePathname(normalizedFile); + + pathnameToFilename.set(pathname, normalizedFile); + + return pathname; + }) + .filter(Boolean); diff --git a/apps/site/mdx/components.mjs b/apps/site/router/mdx/components.mjs similarity index 98% rename from apps/site/mdx/components.mjs rename to apps/site/router/mdx/components.mjs index 46db7dc2afe11..3aafc85353bcf 100644 --- a/apps/site/mdx/components.mjs +++ b/apps/site/router/mdx/components.mjs @@ -46,7 +46,7 @@ import { ReleaseProvider } from '#site/providers/releaseProvider'; /** * A full list of React Components that we want to pass through to MDX * - * @satisfies {import('mdx/types').MDXComponents} + * @type {import('mdx/types').MDXComponents} */ export default { // HTML overrides diff --git a/apps/site/router/mdx/plugins.ts b/apps/site/router/mdx/plugins.ts new file mode 100644 index 0000000000000..306b8dc058592 --- /dev/null +++ b/apps/site/router/mdx/plugins.ts @@ -0,0 +1,31 @@ +'use strict'; + +import rehypeAutolinkHeadings from 'rehype-autolink-headings'; +import rehypeSlug from 'rehype-slug'; +import remarkGfm from 'remark-gfm'; + +import type { CompileOptions } from '@mdx-js/mdx'; + +import remarkHeadings from './plugins/headings.mjs'; +import rehypeShikiji from './plugins/shiki.mjs'; +import remarkTables from './plugins/table.mjs'; + +export default (output: Record): CompileOptions => ({ + rehypePlugins: [ + // Generates `id` attributes for headings (H1, ...) + rehypeSlug, + // Automatically add anchor links to headings (H1, ...) + [rehypeAutolinkHeadings, { behavior: 'wrap' }], + // Transforms sequential code elements into code tabs and + // adds our syntax highlighter (Shikiji) to Codeboxes + rehypeShikiji, + ], + remarkPlugins: [ + // Support GFM syntax to be used within Markdown + remarkGfm, + // Headings + [remarkHeadings, { output }], + // Tables + remarkTables, + ], +}); diff --git a/apps/site/router/mdx/plugins/headings.mjs b/apps/site/router/mdx/plugins/headings.mjs new file mode 100644 index 0000000000000..8443880784747 --- /dev/null +++ b/apps/site/router/mdx/plugins/headings.mjs @@ -0,0 +1,20 @@ +import { toString } from 'mdast-util-to-string'; +import { visit } from 'unist-util-visit'; + +export const headings = root => { + const list = []; + + visit(root, 'heading', node => { + list.push({ + depth: node.depth, + value: toString(node, { includeImageAlt: false }), + }); + }); + + return list; +}; + +export default ({ output }) => + tree => { + output.headings = headings(tree); + }; diff --git a/apps/site/router/mdx/plugins/shiki.mjs b/apps/site/router/mdx/plugins/shiki.mjs new file mode 100644 index 0000000000000..1bc4af848752b --- /dev/null +++ b/apps/site/router/mdx/plugins/shiki.mjs @@ -0,0 +1,23 @@ +import rehypeShikiji from '@node-core/rehype-shiki/plugin'; + +// TODO(@avivkeller): When available, use `OPEN_NEXT_CLOUDFLARE` environment +// variable for detection instead of current method, which will enable better +// tree-shaking. +// Reference: https://github.com/nodejs/nodejs.org/pull/7896#issuecomment-3009480615 +const OPEN_NEXT_CLOUDFLARE = 'Cloudflare' in global; + +// Shiki is created out here to avoid an async rehype plugin +const shiki = await rehypeShikiji({ + // We use the faster WASM engine on the server instead of the web-optimized version. + // + // Currently we fall back to the JavaScript RegEx engine + // on Cloudflare workers because `shiki/wasm` requires loading via + // `WebAssembly.instantiate` with custom imports, which Cloudflare doesn't support + // for security reasons. + wasm: !OPEN_NEXT_CLOUDFLARE, + + // TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare + twoslash: !OPEN_NEXT_CLOUDFLARE, +}); + +export default () => shiki; diff --git a/apps/site/router/mdx/plugins/table.mjs b/apps/site/router/mdx/plugins/table.mjs new file mode 100644 index 0000000000000..d36063e2da6a6 --- /dev/null +++ b/apps/site/router/mdx/plugins/table.mjs @@ -0,0 +1,44 @@ +import { toString } from 'mdast-util-to-string'; +import { visit } from 'unist-util-visit'; + +/** + * Remark plugin that adds data-label attributes to table cells (td) + * based on their corresponding table headers (th). + */ +export default () => tree => { + visit(tree, 'table', table => { + // Ensure table has at least a header row and one data row + if (table.children.length < 2) { + return; + } + + const [headerRow, ...dataRows] = table.children; + + if (headerRow.children.length <= 1) { + table.data ??= {}; + + table.data.hProperties = { + 'data-cards': 'false', + }; + } + + // Extract header labels from the first row + const headerLabels = headerRow.children.map(headerCell => + toString(headerCell.children) + ); + + // Assign data-label to each cell in data rows + dataRows.forEach(row => { + row.children.forEach((cell, idx) => { + if (idx > headerLabels.length - 1) { + return; + } + cell.data ??= {}; + + cell.data.hProperties = { + 'data-label': headerLabels[idx], + }; + }); + }); + }); +}; diff --git a/apps/site/router/page.ts b/apps/site/router/page.ts new file mode 100644 index 0000000000000..5388ac1e89c5e --- /dev/null +++ b/apps/site/router/page.ts @@ -0,0 +1,45 @@ +import { defaultLocale } from '@node-core/website-i18n'; + +import { joinNested } from '#site/util/array'; + +import type { DynamicParams } from '#site/types/page'; +import type { Metadata } from 'next'; + +import { PAGE_VIEWPORT } from './constants'; + +import { getPageMetadata } from '.'; + +/** + * This is the default Viewport Metadata + * + * @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function + */ +export const generateViewport = () => PAGE_VIEWPORT; + +export type PageParams = DynamicParams<{ path: Array }> & { + prefix?: string; +}; + +/** + * This generates each page's HTML Metadata + * + * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata + */ +export const generateMetadata = async ({ + params, + prefix, +}: PageParams): Promise => { + const { path = [], locale = defaultLocale.code } = await params; + + return getPageMetadata(locale, joinNested(prefix, path)); +}; + +// Enforces that this route is used as static rendering +// Except whenever on the Development mode as we want instant-refresh when making changes +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic +export const dynamic = 'force-static'; + +// Ensures that this endpoint is invalidated and re-executed every X minutes +// so that when new deployments happen, the data is refreshed +// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate +export const revalidate = 300; diff --git a/apps/site/router/render.tsx b/apps/site/router/render.tsx new file mode 100644 index 0000000000000..3ccf08fc34085 --- /dev/null +++ b/apps/site/router/render.tsx @@ -0,0 +1,21 @@ +import { setClientContext } from '#site/client-context'; +import WithLayout from '#site/components/withLayout'; +import { MatterProvider } from '#site/providers/matterProvider'; + +import type { MarkdownFile } from '../types'; +import type { FC } from 'react'; + +export const renderPage: FC = ({ content, ...props }) => { + // Defines a shared Server Context for the Client-Side + // That is shared for all pages under the dynamic router + setClientContext(props); + + // The Matter Provider allows Client-Side injection of the data + // to a shared React Client Provider even though the page is rendered + // within a server-side context + return ( + + {content} + + ); +}; diff --git a/apps/site/scripts/blog-data/__test__/generate.test.mjs b/apps/site/scripts/blog-data/__test__/generate.test.mjs index 91c3ed7cdd2c9..47cd98e775ed6 100644 --- a/apps/site/scripts/blog-data/__test__/generate.test.mjs +++ b/apps/site/scripts/blog-data/__test__/generate.test.mjs @@ -3,6 +3,8 @@ import { normalize } from 'node:path'; import { Readable } from 'node:stream'; import { describe, it, mock } from 'node:test'; +import { CONTENT_ROOT } from '#site/next.helpers.mjs'; + let files = []; mock.module('node:fs', { @@ -20,8 +22,9 @@ mock.module('node:fs', { }, }); -mock.module('../../../next.helpers.mjs', { +mock.module('#site/next.helpers.mjs', { namedExports: { + CONTENT_ROOT, getMarkdownFiles: () => { return Promise.resolve(files.map(file => file.path)); }, diff --git a/apps/site/scripts/blog-data/generate.mjs b/apps/site/scripts/blog-data/generate.mjs index b7946ef3e7e5a..42bda2729384d 100644 --- a/apps/site/scripts/blog-data/generate.mjs +++ b/apps/site/scripts/blog-data/generate.mjs @@ -4,12 +4,13 @@ import { createReadStream } from 'node:fs'; import { basename, extname, join } from 'node:path'; import readline from 'node:readline'; +import { defaultLocale } from '@node-core/website-i18n/index.mjs'; import graymatter from 'gray-matter'; -import { getMarkdownFiles } from '#site/next.helpers.mjs'; +import { CONTENT_ROOT, getMarkdownFiles } from '#site/next.helpers.mjs'; // gets the current blog path based on local module path -const blogPath = join(process.cwd(), 'pages/en/blog'); +const blogPath = join(CONTENT_ROOT, defaultLocale.code, 'blog'); /** * This method parses the source (raw) Markdown content into Frontmatter @@ -55,9 +56,10 @@ const getFrontMatter = (filename, source) => { */ const generateBlogData = async () => { // We retrieve the full pathnames of all Blog Posts to read each file individually - const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [ - '**/index.md', - ]); + const filenames = await getMarkdownFiles({ + cwd: blogPath, + exclude: ['**/index.md'], + }); /** * This contains the metadata of all available blog categories diff --git a/apps/site/scripts/orama-search/get-documents.mjs b/apps/site/scripts/orama-search/get-documents.mjs index b6d4ffdf73fc2..72ceba2b540b3 100644 --- a/apps/site/scripts/orama-search/get-documents.mjs +++ b/apps/site/scripts/orama-search/get-documents.mjs @@ -1,8 +1,10 @@ -import { readFile, glob } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { join, basename, posix, win32 } from 'node:path'; +import { defaultLocale } from '@node-core/website-i18n/index.mjs'; + import generateReleaseData from '#site/next-data/generators/releaseData.mjs'; -import { getRelativePath } from '#site/next.helpers.mjs'; +import { CONTENT_ROOT, getMarkdownFiles } from '#site/next.helpers.mjs'; import { processDocument } from './process-documents.mjs'; @@ -47,22 +49,22 @@ export const getAPIDocs = async () => { }; /** - * Collect all local markdown/mdx articles under /pages/en, + * Collect all local markdown/mdx articles, * excluding blog content. */ export const getArticles = async () => { - const relativePath = getRelativePath(import.meta.url); - const root = join(relativePath, '..', '..', 'pages', 'en'); - // Find all markdown files (excluding blog) - const files = await Array.fromAsync(glob('**/*.{md,mdx}', { cwd: root })); + const files = await getMarkdownFiles(); // Read content + metadata return Promise.all( files .filter(path => !path.startsWith('blog')) .map(async path => ({ - content: await readFile(join(root, path), 'utf8'), + content: await readFile( + join(CONTENT_ROOT, defaultLocale.code, path), + 'utf8' + ), pathname: path // Strip the extension .replace(/\.mdx?$/, '') diff --git a/apps/site/types/index.ts b/apps/site/types/index.ts index 3e0fd77eb4e11..8e8ad943a2124 100644 --- a/apps/site/types/index.ts +++ b/apps/site/types/index.ts @@ -1,7 +1,7 @@ export * from './blog'; export * from './config'; export * from './features'; -export * from './frontmatter'; +export * from './markdown'; export * from './i18n'; export * from './layouts'; export * from './navigation'; diff --git a/apps/site/types/frontmatter.ts b/apps/site/types/markdown.ts similarity index 63% rename from apps/site/types/frontmatter.ts rename to apps/site/types/markdown.ts index c625bc1d24405..44e7fa2b53055 100644 --- a/apps/site/types/frontmatter.ts +++ b/apps/site/types/markdown.ts @@ -1,4 +1,6 @@ import type { Layouts } from './layouts'; +import type { ServerContext } from './server'; +import type { ReactNode } from 'react'; // TODO(@avivkeller): BlogFrontmatter, LearnFrontmatter, etc export type Frontmatter = { @@ -10,4 +12,9 @@ export type Frontmatter = { authors?: string; category?: string; description?: string; + canonical?: string; +}; + +export type MarkdownFile = ServerContext & { + content: ReactNode; }; diff --git a/apps/site/types/server.ts b/apps/site/types/server.ts index 11f02ee74a8b6..5523cfd82b054 100644 --- a/apps/site/types/server.ts +++ b/apps/site/types/server.ts @@ -1,12 +1,14 @@ import type { useDetectOS } from '#site/hooks'; -import type { Frontmatter } from '#site/types/frontmatter'; -import type { Heading } from '@vcarl/remark-headings'; +import type { Frontmatter } from '#site/types/markdown'; import type { ReadTimeResults } from 'reading-time'; -export type ClientSharedServerContext = { +export type ServerContext = { frontmatter: Frontmatter; - headings: Array; + headings: Array<{ depth: number; value: string }>; pathname: string; filename: string; readingTime: ReadTimeResults; -} & ReturnType; +}; + +export type ClientSharedServerContext = ServerContext & + ReturnType; diff --git a/apps/site/util/__tests__/table.test.mjs b/apps/site/util/__tests__/table.test.mjs deleted file mode 100644 index 7ae4a004075a6..0000000000000 --- a/apps/site/util/__tests__/table.test.mjs +++ /dev/null @@ -1,434 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import remarkTableTitles from '#site/util/table'; - -describe('remarkTableTitles', () => { - it('should add data-label attributes to table cells based on headers', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Name' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'Age' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'City' }], - }, - ], - }, - // Data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'John' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '25' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'NYC' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const dataRow = table.children[1]; - - assert.equal(dataRow.children[0].data.hProperties['data-label'], 'Name'); - assert.equal(dataRow.children[1].data.hProperties['data-label'], 'Age'); - assert.equal(dataRow.children[2].data.hProperties['data-label'], 'City'); - }); - - it('should handle multiple data rows', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Product' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'Price' }], - }, - ], - }, - // First data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Apple' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '$1.00' }], - }, - ], - }, - // Second data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Banana' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '$0.50' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const firstDataRow = table.children[1]; - const secondDataRow = table.children[2]; - - assert.equal( - firstDataRow.children[0].data.hProperties['data-label'], - 'Product' - ); - assert.equal( - firstDataRow.children[1].data.hProperties['data-label'], - 'Price' - ); - assert.equal( - secondDataRow.children[0].data.hProperties['data-label'], - 'Product' - ); - assert.equal( - secondDataRow.children[1].data.hProperties['data-label'], - 'Price' - ); - }); - - it('should add data-cards="false" for single column tables', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row with single column - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Items' }], - }, - ], - }, - // Data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Item 1' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - - assert.equal(table.data.hProperties['data-cards'], 'false'); - }); - - it('should handle empty tables (less than 2 rows)', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Only header row, no data rows - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Header' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - - // Should not crash and should not modify the table - assert.equal(table.children.length, 1); - }); - - it('should handle cells with more columns than headers', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row with 2 columns - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Name' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'Age' }], - }, - ], - }, - // Data row with 3 columns (more than headers) - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'John' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '25' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'Extra' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const dataRow = table.children[1]; - - assert.equal(dataRow.children[0].data.hProperties['data-label'], 'Name'); - assert.equal(dataRow.children[1].data.hProperties['data-label'], 'Age'); - // Third cell should not have data-label since there's no corresponding header - assert.deepEqual(dataRow.children[2].data, undefined); - }); - - it('should handle complex header content with nested elements', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row with complex content - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [ - { - type: 'emphasis', - children: [{ type: 'text', value: 'Product' }], - }, - { type: 'text', value: ' Name' }, - ], - }, - { - type: 'tableCell', - children: [ - { - type: 'strong', - children: [{ type: 'text', value: 'Price' }], - }, - ], - }, - ], - }, - // Data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Apple' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '$1.00' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const dataRow = table.children[1]; - - assert.equal( - dataRow.children[0].data.hProperties['data-label'], - 'Product Name' - ); - assert.equal(dataRow.children[1].data.hProperties['data-label'], 'Price'); - }); - - it('should preserve existing cell data and merge with new hProperties', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'Name' }], - }, - ], - }, - // Data row with existing data - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'John' }], - data: { - existingProperty: 'value', - hProperties: { - className: 'existing-class', - }, - }, - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const dataRow = table.children[1]; - const cell = dataRow.children[0]; - - assert.equal(cell.data.existingProperty, 'value'); - assert.equal(cell.data.hProperties['data-label'], 'Name'); - }); - - it('should handle empty header cells', () => { - const tree = { - type: 'root', - children: [ - { - type: 'table', - children: [ - // Header row with empty cell - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: 'Age' }], - }, - ], - }, - // Data row - { - type: 'tableRow', - children: [ - { - type: 'tableCell', - children: [{ type: 'text', value: 'John' }], - }, - { - type: 'tableCell', - children: [{ type: 'text', value: '25' }], - }, - ], - }, - ], - }, - ], - }; - - const plugin = remarkTableTitles(); - plugin(tree); - - const table = tree.children[0]; - const dataRow = table.children[1]; - - assert.equal(dataRow.children[0].data.hProperties['data-label'], ''); - assert.equal(dataRow.children[1].data.hProperties['data-label'], 'Age'); - }); -}); diff --git a/apps/site/util/array.ts b/apps/site/util/array.ts index f6aec1ed9c97b..cba6efca6a540 100644 --- a/apps/site/util/array.ts +++ b/apps/site/util/array.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path'; + // Fisher-Yates shuffle algorithm with a seed for deterministic results export const shuffle = async ( array: Array, @@ -21,3 +23,8 @@ export const shuffle = async ( return shuffled; }; + +// Join the arguments like a path +export const joinNested = ( + ...args: Array | undefined> +) => join(...(args.filter(Boolean) as Array).flat()); diff --git a/apps/site/util/table.ts b/apps/site/util/table.ts deleted file mode 100644 index afc51fc5cb14b..0000000000000 --- a/apps/site/util/table.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { toString } from 'mdast-util-to-string'; -import { visit } from 'unist-util-visit'; - -import type { Root } from 'mdast'; - -/** - * Remark plugin that adds data-label attributes to table cells (td) - * based on their corresponding table headers (th). - */ -export default function remarkTableTitles() { - return (tree: Root) => { - visit(tree, 'table', table => { - // Ensure table has at least a header row and one data row - if (table.children.length < 2) { - return; - } - - const [headerRow, ...dataRows] = table.children; - - if (headerRow.children.length <= 1) { - table.data ??= {}; - - table.data.hProperties = { - 'data-cards': 'false', - }; - } - - // Extract header labels from the first row - const headerLabels = headerRow.children.map(headerCell => - toString(headerCell.children) - ); - - // Assign data-label to each cell in data rows - dataRows.forEach(row => { - row.children.forEach((cell, idx) => { - if (idx > headerLabels.length - 1) { - return; - } - cell.data ??= {}; - - cell.data.hProperties = { - 'data-label': headerLabels[idx], - }; - }); - }); - }); - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f3b6f3dd3357..684ec21cb81b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,6 @@ importers: '@heroicons/react': specifier: ~2.2.0 version: 2.2.0(react@19.2.3) - '@mdx-js/mdx': - specifier: ^3.1.1 - version: 3.1.1 '@node-core/rehype-shiki': specifier: workspace:* version: link:../../packages/rehype-shiki @@ -132,9 +129,6 @@ importers: '@types/react': specifier: 'catalog:' version: 19.2.7 - '@vcarl/remark-headings': - specifier: ~0.1.0 - version: 0.1.0 '@vercel/analytics': specifier: ~1.5.0 version: 1.5.0(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -168,6 +162,9 @@ importers: next-intl: specifier: ~4.5.3 version: 4.5.8(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + next-mdx-remote: + specifier: ^5.0.0 + version: 5.0.0(@types/react@19.2.7)(react@19.2.3) next-themes: specifier: ~0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -192,9 +189,6 @@ importers: remark-gfm: specifier: ~4.0.1 version: 4.0.1 - remark-reading-time: - specifier: ~2.0.2 - version: 2.0.2 semver: specifier: ~7.7.3 version: 7.7.3 @@ -210,16 +204,13 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.0.0 - vfile: - specifier: ~6.0.3 - version: 6.0.3 - vfile-matter: - specifier: ~5.0.1 - version: 5.0.1 devDependencies: '@flarelabs-net/wrangler-build-time-fs-assets-polyfilling': specifier: ^0.0.1 version: 0.0.1 + '@mdx-js/mdx': + specifier: ^3.1.1 + version: 3.1.1 '@next/eslint-plugin-next': specifier: 16.0.7 version: 16.0.7 @@ -2056,6 +2047,12 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + '@minify-html/node-darwin-arm64@0.16.4': resolution: {integrity: sha512-9H8hcywDb8zo2jEJfaIAibgsKjMqE+XF7SyqTtJ5H8lVXHxffOkawH4TQtphf9V/x7zXeb/nByAvHe1orJ/RHA==} cpu: [arm64] @@ -5213,9 +5210,6 @@ packages: estree-util-build-jsx@3.0.1: resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} - estree-util-is-identifier-name@2.1.0: - resolution: {integrity: sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ==} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -5225,9 +5219,6 @@ packages: estree-util-to-js@2.0.0: resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} - estree-util-value-to-estree@3.4.0: - resolution: {integrity: sha512-Zlp+gxis+gCfK12d3Srl2PdX2ybsEA8ZYy6vQGVQTNNYLEGRQQ56XB64bjemN8kxIKXP1nC9ip4Z+ILy9LGzvQ==} - estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} @@ -6631,6 +6622,12 @@ packages: typescript: optional: true + next-mdx-remote@5.0.0: + resolution: {integrity: sha512-RNNbqRpK9/dcIFZs/esQhuLA8jANqlH694yqoDBK8hkVdJUndzzGmnPHa2nyi90N4Z9VmzuSWNRpr5ItT3M7xQ==} + engines: {node: '>=14', npm: '>=7'} + peerDependencies: + react: '>=16' + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -7494,9 +7491,6 @@ packages: remark-preset-lint-recommended@7.0.1: resolution: {integrity: sha512-j1CY5u48PtZl872BQ40uWSQMT3R4gXKp0FUgevMu5gW7hFMtvaCiDq+BfhzeR8XKKiW9nIMZGfIMZHostz5X4g==} - remark-reading-time@2.0.2: - resolution: {integrity: sha512-ILjIuR0dQQ8pELPgaFvz7ralcSN62rD/L1pTUJgWb4gfua3ZwYEI8mnKGxEQCbrXSUF/OvycTkcUbifGOtOn5A==} - remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} @@ -8284,6 +8278,9 @@ packages: unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove@3.1.1: + resolution: {integrity: sha512-kfCqZK5YVY5yEa89tvpl7KnBBHu2c6CzMkqHUrlOqaRgGOMp0sMvwWOVrbAtj03KhovQB7i96Gda72v/EFE0vw==} + unist-util-remove@4.0.0: resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} @@ -8296,18 +8293,12 @@ packages: unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - unist-util-visit-parents@4.1.1: - resolution: {integrity: sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==} - unist-util-visit-parents@5.1.3: resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} - unist-util-visit@3.1.0: - resolution: {integrity: sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==} - unist-util-visit@4.1.2: resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} @@ -10614,6 +10605,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.7 + react: 19.2.3 + '@minify-html/node-darwin-arm64@0.16.4': optional: true @@ -14409,8 +14406,6 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-walker: 3.0.3 - estree-util-is-identifier-name@2.1.0: {} - estree-util-is-identifier-name@3.0.0: {} estree-util-scope@1.0.0: @@ -14424,10 +14419,6 @@ snapshots: astring: 1.9.0 source-map: 0.7.6 - estree-util-value-to-estree@3.4.0: - dependencies: - '@types/estree': 1.0.8 - estree-util-visit@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -16248,6 +16239,19 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + next-mdx-remote@5.0.0(@types/react@19.2.7)(react@19.2.3): + dependencies: + '@babel/code-frame': 7.27.1 + '@mdx-js/mdx': 3.1.1 + '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + unist-util-remove: 3.1.1 + vfile: 6.0.3 + vfile-matter: 5.0.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -17412,13 +17416,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-reading-time@2.0.2: - dependencies: - estree-util-is-identifier-name: 2.1.0 - estree-util-value-to-estree: 3.4.0 - reading-time: 1.5.0 - unist-util-visit: 3.1.0 - remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 @@ -18448,6 +18445,12 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove@3.1.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + unist-util-remove@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -18470,11 +18473,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-visit-parents@4.1.1: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - unist-util-visit-parents@5.1.3: dependencies: '@types/unist': 2.0.11 @@ -18485,12 +18483,6 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.0 - unist-util-visit@3.1.0: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - unist-util-visit-parents: 4.1.1 - unist-util-visit@4.1.2: dependencies: '@types/unist': 2.0.11 From d74c39e26b9d3e7f1eb678cb9ee906e2badb3da9 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 2 Jan 2026 17:21:08 -0500 Subject: [PATCH 2/2] fixup! --- apps/site/app/sitemap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/app/sitemap.ts b/apps/site/app/sitemap.ts index 2a18465a636d8..ded177e2e96e5 100644 --- a/apps/site/app/sitemap.ts +++ b/apps/site/app/sitemap.ts @@ -3,7 +3,7 @@ import { availableLocaleCodes, defaultLocale } from '@node-core/website-i18n'; import { BASE_PATH } from '#site/next.constants.mjs'; import { BASE_URL } from '#site/next.constants.mjs'; import { EXTERNAL_LINKS_SITEMAP } from '#site/next.constants.mjs'; -import { BLOG_DYNAMIC_ROUTES } from '#site/router/constants.js'; +import { BLOG_DYNAMIC_ROUTES } from '#site/router/constants'; import type { MetadataRoute } from 'next';