From 6b9bf68012acba55a98f620835ed78c8e7815f02 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 20 Jun 2026 18:48:07 -0600 Subject: [PATCH 01/17] checkpoint: move site away from rsc --- .../lighthouse-shim-vs-react-2026-04-20.md | 85 --- docs/performance-plan-home-library-docs.md | 216 ------- docs/ssr-rsc-migration-findings.md | 106 ++++ package.json | 18 +- packages/highlight/package.json | 29 + packages/highlight/src/index.ts | 574 ++++++++++++++++++ packages/highlight/src/markdown.ts | 117 ++++ packages/highlight/src/react.ts | 36 ++ packages/highlight/src/rehype.ts | 103 ++++ packages/highlight/src/remark.ts | 78 +++ packages/markdown/package.json | 52 ++ packages/markdown/src/extensions/callouts.ts | 47 ++ .../src/extensions/comment-components.ts | 106 ++++ packages/markdown/src/extensions/docs.ts | 43 ++ packages/markdown/src/extensions/framework.ts | 50 ++ packages/markdown/src/extensions/headings.ts | 99 +++ packages/markdown/src/extensions/shared.ts | 123 ++++ packages/markdown/src/extensions/tabs.ts | 185 ++++++ packages/markdown/src/html.ts | 250 ++++++++ packages/markdown/src/index.ts | 9 + packages/markdown/src/inline.ts | 485 +++++++++++++++ packages/markdown/src/parser.ts | 476 +++++++++++++++ packages/markdown/src/react.ts | 425 +++++++++++++ packages/markdown/src/types.ts | 243 ++++++++ packages/markdown/src/utils.ts | 80 +++ pnpm-lock.yaml | 369 +---------- pnpm-workspace.yaml | 4 + rsc-migration-report.md | 197 ------ src/auth/cli-tickets.server.ts | 8 +- src/components/Breadcrumbs.tsx | 2 +- src/components/CodeExplorer.tsx | 17 +- src/components/DeferredApplicationStarter.tsx | 84 --- src/components/Doc.tsx | 13 +- src/components/DocBreadcrumb.tsx | 2 +- src/components/FrameworkIconTabs.tsx | 2 - ...ection.tsx => LandingCommunitySection.tsx} | 9 +- src/components/Navbar.tsx | 61 +- src/components/NavbarAuthControls.tsx | 2 - src/components/SearchModal.tsx | 2 - ...ySponsorSection.tsx => SponsorSection.tsx} | 6 +- src/components/ThemeProvider.tsx | 2 - src/components/ToastProvider.tsx | 2 - src/components/Toc.tsx | 2 +- src/components/TocMobile.tsx | 2 +- src/components/home/HomeCommunitySection.tsx | 39 +- .../home/HomeSocialProofSection.tsx | 52 ++ src/components/landing/AiLanding.tsx | 22 +- src/components/landing/CliLanding.tsx | 21 +- src/components/landing/ConfigLanding.tsx | 21 +- src/components/landing/DbLanding.tsx | 21 +- src/components/landing/DevtoolsLanding.tsx | 21 +- src/components/landing/FormLanding.tsx | 20 +- src/components/landing/HotkeysLanding.tsx | 21 +- src/components/landing/IntentLanding.tsx | 21 +- ....server.tsx => LandingCodeExampleCard.tsx} | 26 +- src/components/landing/PacerLanding.tsx | 21 +- src/components/landing/QueryLanding.tsx | 20 +- src/components/landing/RangerLanding.tsx | 21 +- src/components/landing/RouterLanding.tsx | 15 +- src/components/landing/StartLanding.tsx | 16 +- src/components/landing/StoreLanding.tsx | 21 +- src/components/landing/TableLanding.tsx | 20 +- src/components/landing/VirtualLanding.tsx | 21 +- src/components/landing/WorkflowLanding.tsx | 21 +- ...odeExamples.server.tsx => codeExamples.ts} | 79 +-- src/components/markdown/BundlerTabs.tsx | 2 - src/components/markdown/CodeBlock.server.tsx | 41 -- src/components/markdown/CodeBlock.tsx | 58 +- src/components/markdown/CodeBlockView.tsx | 2 - src/components/markdown/FileTabs.tsx | 2 - src/components/markdown/FrameworkContent.tsx | 2 - src/components/markdown/Markdown.tsx | 164 +++++ src/components/markdown/MarkdownContent.tsx | 41 +- .../markdown/MarkdownHeadingContext.tsx | 30 - src/components/markdown/MarkdownLink.tsx | 2 - src/components/markdown/MdComponents.tsx | 117 +++- src/components/markdown/MermaidBlock.tsx | 2 - .../markdown/PackageManagerTabs.tsx | 2 - src/components/markdown/Tabs.tsx | 2 - src/components/markdown/codeBlock.shared.tsx | 84 ++- src/components/markdown/index.ts | 4 - .../markdown/renderCodeBlock.server.tsx | 90 --- .../markdown/usePersistedEnumStore.ts | 2 - src/components/stack/CategoryArticle.tsx | 4 +- src/contexts/LoginModalContext.tsx | 2 - src/contexts/SearchContext.tsx | 2 - src/queries/docsConfig.ts | 25 + src/routes/-library-landing-route.tsx | 19 +- src/routes/-library-landing.tsx | 22 +- src/routes/__root.tsx | 226 +++---- src/routes/_library.tsx | 34 +- .../_library/$libraryId/$version.docs.$.tsx | 9 +- .../$version.docs.framework.$framework.$.tsx | 9 +- ...n.docs.framework.$framework.examples.$.tsx | 31 +- src/routes/_library/$libraryId/$version.tsx | 12 +- src/routes/_library/ai.$version.index.tsx | 4 +- src/routes/_library/cli.$version.index.tsx | 4 +- src/routes/_library/config.$version.index.tsx | 4 +- src/routes/_library/db.$version.index.tsx | 4 +- .../_library/devtools.$version.index.tsx | 4 +- src/routes/_library/form.$version.index.tsx | 4 +- .../_library/hotkeys.$version.index.tsx | 4 +- src/routes/_library/intent.$version.index.tsx | 4 +- src/routes/_library/pacer.$version.index.tsx | 4 +- src/routes/_library/query.$version.index.tsx | 4 +- src/routes/_library/ranger.$version.index.tsx | 4 +- src/routes/_library/store.$version.index.tsx | 4 +- src/routes/_library/table.$version.index.tsx | 4 +- .../_library/virtual.$version.index.tsx | 4 +- .../_library/workflow.$version.index.tsx | 4 +- src/routes/blog.$.tsx | 8 +- src/routes/index.tsx | 165 ++--- .../registry/$packageName.$skillName.tsx | 6 +- src/styles/app.css | 120 +++- src/ui/MarkdownImg.tsx | 52 +- src/utils/auth.server.ts | 12 +- src/utils/blog.functions.ts | 11 +- src/utils/codeBlock.functions.ts | 58 -- src/utils/docs.functions.ts | 23 - src/utils/docs.ts | 29 - src/utils/github-content-cache.server.ts | 22 + src/utils/intent.functions.ts | 7 +- src/utils/landing-code-example.functions.ts | 26 - src/utils/markdown/index.ts | 8 +- src/utils/markdown/plugins/collectHeadings.ts | 104 ---- src/utils/markdown/plugins/extractCodeMeta.ts | 57 -- src/utils/markdown/plugins/helpers.ts | 26 - src/utils/markdown/plugins/index.ts | 8 - .../plugins/parseCommentComponents.ts | 103 ---- .../plugins/transformCommentComponents.ts | 23 - .../plugins/transformFrameworkComponent.ts | 217 ------- .../plugins/transformTabsComponent.ts | 456 -------------- src/utils/markdown/processor.rsc.tsx | 206 ------- src/utils/markdown/processor.ts | 22 + src/utils/markdown/renderRsc.tsx | 24 - src/utils/publicImageDimensions.ts | 69 +++ src/utils/useDeploymentProviderPlacement.ts | 2 - src/utils/usePartnerPlacementContext.ts | 2 - vite.config.ts | 107 ++-- 139 files changed, 4938 insertions(+), 3498 deletions(-) delete mode 100644 docs/perf/lighthouse-shim-vs-react-2026-04-20.md delete mode 100644 docs/performance-plan-home-library-docs.md create mode 100644 docs/ssr-rsc-migration-findings.md create mode 100644 packages/highlight/package.json create mode 100644 packages/highlight/src/index.ts create mode 100644 packages/highlight/src/markdown.ts create mode 100644 packages/highlight/src/react.ts create mode 100644 packages/highlight/src/rehype.ts create mode 100644 packages/highlight/src/remark.ts create mode 100644 packages/markdown/package.json create mode 100644 packages/markdown/src/extensions/callouts.ts create mode 100644 packages/markdown/src/extensions/comment-components.ts create mode 100644 packages/markdown/src/extensions/docs.ts create mode 100644 packages/markdown/src/extensions/framework.ts create mode 100644 packages/markdown/src/extensions/headings.ts create mode 100644 packages/markdown/src/extensions/shared.ts create mode 100644 packages/markdown/src/extensions/tabs.ts create mode 100644 packages/markdown/src/html.ts create mode 100644 packages/markdown/src/index.ts create mode 100644 packages/markdown/src/inline.ts create mode 100644 packages/markdown/src/parser.ts create mode 100644 packages/markdown/src/react.ts create mode 100644 packages/markdown/src/types.ts create mode 100644 packages/markdown/src/utils.ts delete mode 100644 rsc-migration-report.md delete mode 100644 src/components/DeferredApplicationStarter.tsx rename src/components/{LazyLandingCommunitySection.tsx => LandingCommunitySection.tsx} (88%) rename src/components/{LazySponsorSection.tsx => SponsorSection.tsx} (94%) rename src/components/landing/{LandingCodeExampleCard.server.tsx => LandingCodeExampleCard.tsx} (79%) rename src/components/landing/{codeExamples.server.tsx => codeExamples.ts} (78%) delete mode 100644 src/components/markdown/CodeBlock.server.tsx create mode 100644 src/components/markdown/Markdown.tsx delete mode 100644 src/components/markdown/MarkdownHeadingContext.tsx delete mode 100644 src/components/markdown/renderCodeBlock.server.tsx create mode 100644 src/queries/docsConfig.ts delete mode 100644 src/utils/codeBlock.functions.ts delete mode 100644 src/utils/landing-code-example.functions.ts delete mode 100644 src/utils/markdown/plugins/collectHeadings.ts delete mode 100644 src/utils/markdown/plugins/extractCodeMeta.ts delete mode 100644 src/utils/markdown/plugins/helpers.ts delete mode 100644 src/utils/markdown/plugins/index.ts delete mode 100644 src/utils/markdown/plugins/parseCommentComponents.ts delete mode 100644 src/utils/markdown/plugins/transformCommentComponents.ts delete mode 100644 src/utils/markdown/plugins/transformFrameworkComponent.ts delete mode 100644 src/utils/markdown/plugins/transformTabsComponent.ts delete mode 100644 src/utils/markdown/processor.rsc.tsx create mode 100644 src/utils/markdown/processor.ts delete mode 100644 src/utils/markdown/renderRsc.tsx create mode 100644 src/utils/publicImageDimensions.ts diff --git a/docs/perf/lighthouse-shim-vs-react-2026-04-20.md b/docs/perf/lighthouse-shim-vs-react-2026-04-20.md deleted file mode 100644 index fa6508ba1..000000000 --- a/docs/perf/lighthouse-shim-vs-react-2026-04-20.md +++ /dev/null @@ -1,85 +0,0 @@ -# Lighthouse: @tanstack/dom-vite shim vs. real React - -**Date:** 2026-04-20 -**tanstack.com commit at time of measurement:** `fb806bb` (shim build with `@tanstack/dom-vite@0.1.0-alpha.5`, pulling `@tanstack/react-dom@0.1.0-alpha.4` — includes the RSC deferred-hydration adoption fix landed the same day). -**React baseline build:** same source tree with `tanstackDom()` plugin removed from `vite.config.ts` and `serverVariantAliases` dropped — i.e. stock `react@19.2.3` / `react-dom@19.2.3`. - -## TL;DR - -- **Performance score: parity** (±2 across pages / form factors, within run-to-run noise). -- **FCP: consistent shim win** everywhere — ~4% on home, ~11–17% on docs / blog. Smaller main-thread parse cost lets first paint land sooner. -- **LCP: shim regresses on RSC-heavy pages, desktop** — the LCP element on docs / blog pages lives in the Flight-streamed subtree, and the shim's `use(pendingPromise)` + deferred-resume adds latency vs. React's battle-tested RSC client. Mobile is mostly parity (network-bound anyway). -- **TBT / CLS: effectively zero on both** after the same-day RSC hydration fix — no duplicate DOM, no layout shift from appending. -- **Bundle (raw JS): −4.7%** on tanstack.com (-980 KB of 21 MB total client JS). Modest because router / store / app code dominates; shim only replaces React's share. - -## Methodology - -1. `pnpm build` for each variant. -2. `PORT=4000 pnpm start:prod` to serve from `dist/server/server.js` on `http://localhost:4000`. -3. **5 trials × 3 URLs × 2 form factors = 30 Lighthouse runs per variant** using `npx lighthouse` v13 with `--only-categories=performance` and headless Chrome. -4. Mobile runs use Lighthouse's default emulation (slow 4G + 4× CPU slowdown). Desktop uses `--preset=desktop` (no throttling). -5. Medians reported below. - -## Medians - -### Performance score - -| URL | form | React | Shim | Δ | -| --------------------------------------------------- | :-----: | ----: | ---: | --: | -| `/` | desktop | 99 | 99 | 0 | -| `/` | mobile | 87 | 88 | +1 | -| `/query/latest/docs/framework/react/guides/queries` | desktop | 96 | 96 | 0 | -| `/query/latest/docs/framework/react/guides/queries` | mobile | 64 | 66 | +2 | -| `/blog/react-server-components` | desktop | 98 | 96 | −2 | -| `/blog/react-server-components` | mobile | 70 | 71 | +1 | - -### Web Vitals - -| URL | form | FCP (R → S) | LCP (R → S) | TBT (R → S) | CLS (R → S) | TTI (R → S) | -| ------------------------------- | :-----: | :------------------: | :------------------: | :----------------: | :---------: | :-----------: | -| `/` | desktop | 0.61s → 0.59s (−4%) | 0.84s → 0.91s (+8%) | 0ms → 0ms | 0 → 0 | 0.84s → 0.92s | -| `/` | mobile | 2.34s → 2.31s | 3.71s → 3.60s (−3%) | 19ms → 20ms | 0 → 0 | 5.54s → 5.55s | -| `/query/.../queries` | desktop | 1.05s → 0.92s (−13%) | 1.05s → 1.24s (+18%) | 0ms → 0ms | 0 → 0 | 1.05s → 1.24s | -| `/query/.../queries` | mobile | 4.66s → 4.13s (−11%) | 6.62s → 6.39s (−3%) | 17ms → 19ms | 0 → 0 | 8.36s → 8.41s | -| `/blog/react-server-components` | desktop | 0.90s → 0.74s (−17%) | 0.90s → 1.29s (+43%) | 0ms → 0ms | 0 → 0 | 0.90s → 1.29s | -| `/blog/react-server-components` | mobile | 3.73s → 3.21s (−14%) | 5.32s → 6.23s (+17%) | 34ms → 21ms (−37%) | 0 → 0 | 6.24s → 6.57s | - -### Bundle size (uncompressed total client JS) - -| Build | Total client JS | Notes | -| ---------- | --------------: | ---------------------------------------------------------------------------------------------------- | -| Real React | 21,052 KB | Dedicated `react-*.js` chunk = 176 KB (`manualChunks` splits `node_modules/react{,-dom}/`) | -| Shim | 20,072 KB | No dedicated react chunk; shim code inlines into `app-shell` (+16 KB there). Net **−980 KB (−4.7%)** | - -## Caveats - -- **Lab data only.** Chrome origin-level CWV (CrUX) needs ~28 days of real traffic before aggregates stabilize. Since the shim only went live on `2026-04-20`, field data won't be comparable for a month. -- **`pnpm start:prod` serves from Node locally — no CDN.** Absolute TTFB numbers are dev-machine noise (5ms–1s range depending on cold-cache loader work); anchor on client-side metrics. -- **Per-page LCP percentages can look dramatic when the absolute value is small.** Blog desktop LCP `0.90s → 1.29s` is +390 ms — real, but a sub-second LCP regression in both states is still a Core Web Vitals "Good" rating (<2.5s). -- **Single-node prod server — no edge, no warm cache.** Mobile Lighthouse runs with 4× CPU throttling are inherently high-variance. - -## Reproduce - -```bash -# React baseline -# 1) temporarily remove tanstackDom() plugin + serverVariantAliases in vite.config.ts -pnpm build -PORT=4000 pnpm start:prod & -# run 5 trials × 3 URLs × 2 form factors, save JSON to ./react/ - -# Shim -# 2) restore tanstackDom() plugin + serverVariantAliases -pnpm build -PORT=4000 pnpm start:prod & -# re-run, save JSON to ./shim/ - -# Aggregate medians + delta (parse JSON, compute median of numericValues per audit key) -``` - -See the shim side for the runner + aggregator scripts used (`/tmp/lh-compare/run.sh`, `/tmp/lh-compare/aggregate.mjs` at measurement time). - -## Related shim work shipped with this comparison - -- `@tanstack/react-dom@0.1.0-alpha.4`: `renderFunction`'s deferred-hydration branch now mirrors `renderLazy`'s ancestor-Suspense guard (`_awaitingLazyHydration`). Fixes duplicate-markup on RSC pages. Regression test: `tests/rsc-hydration-adopt.test.tsx`. -- `@tanstack/react-dom-server@0.1.0-alpha.4`: shell-chunk batching in `streamHtml` (reduces Node stream overhead ~3–4% on SSR bench). -- `@tanstack/dom-vite@0.1.0-alpha.5`: dep bump to pick up react-dom@alpha.4. diff --git a/docs/performance-plan-home-library-docs.md b/docs/performance-plan-home-library-docs.md deleted file mode 100644 index 32b505bd2..000000000 --- a/docs/performance-plan-home-library-docs.md +++ /dev/null @@ -1,216 +0,0 @@ -# Homepage, Library Landing, and Docs Performance Plan - -## Goal - -Make the homepage, library landing pages, and docs materially faster, lighter, and cheaper to render without regressing content quality or navigation UX. - -## Current Baseline - -- Homepage route chunk: `dist/client/assets/index-Bq0A5jmY.js` at `564.79 kB / 172.14 kB gzip` -- Shared shell chunk: `dist/client/assets/app-shell-BikUtTEO.js` at `349.55 kB / 110.08 kB gzip` -- Search modal chunk: `dist/client/assets/SearchModal-Bl-tUxqr.js` at `195.54 kB / 54.27 kB gzip` -- Docs shell chunk: `dist/client/assets/DocsLayout-Bga1-HA9.js` at `17.61 kB / 6.05 kB gzip` -- Markdown chrome chunk: `dist/client/assets/MarkdownContent-ia2V1dk8.js` at `19.37 kB / 6.39 kB gzip` -- Global CSS: `dist/client/assets/app-CBMELhsb.css` at `319.24 kB / 40.48 kB gzip` - -## Main Problems - -- Homepage ships too much in one route chunk. -- Library landing pages pay docs-shell and docs-config cost before the user asks for docs. -- Docs still do too much work per request even with GitHub content caching. -- Hidden docs UI still mounts and runs effects/queries. -- Anonymous docs users still trigger auth-related client queries for framework preference. -- Some "lazy" controls are effectively eager. - -## Success Targets - -- Cut the homepage route chunk hard enough that it is no longer one of the top client payloads. -- Remove docs-config and docs-layout from the critical path for landing pages. -- Turn docs page rendering into mostly cached work. -- Avoid client queries on first paint for content that can be rendered server-side. -- Reduce hidden-work JS on docs mobile and desktop layouts. - -## Workstreams - -### 1. Homepage route diet - -Targets: - -- `src/routes/index.tsx` -- `src/components/OpenSourceStats.tsx` -- `src/components/ShowcaseSection.tsx` -- `src/components/PartnersGrid.tsx` -- `src/components/MaintainerCard.tsx` - -Changes: - -- Break below-the-fold homepage sections into viewport-triggered lazy boundaries. -- Move recent posts off client `useQuery` and into route loader or server-rendered data. -- Stop client-fetching OSS stats on initial paint. Render a server snapshot first. -- Keep `DeferredApplicationStarter` deferred by visibility or interaction, not just idle timeout. -- Avoid eagerly importing large static datasets into the first route chunk where possible. -- Stop rendering both light and dark hero image variants eagerly. - -Expected win: - -- Lower homepage JS, lower hydration cost, lower first-load network. - -### 2. Dedicated library landing shell - -Targets: - -- `src/routes/-library-landing.tsx` -- `src/components/DocsLayout.tsx` -- landing components under `src/components/landing/` - -Changes: - -- Introduce a dedicated `LibraryLandingLayout`. -- Remove `DocsLayout` from landing pages. -- Stop fetching docs `config.json` in the landing-page critical path unless a landing section actually needs it. -- Keep framework/version/docs navigation lightweight on landing pages and hand off to docs only when needed. - -Expected win: - -- Better landing-page TTFB, less landing-page JS, less docs chrome on non-docs surfaces. - -### 3. Docs render caching - -Targets: - -- `src/utils/docs.functions.ts` -- `src/utils/github-content-cache.server.ts` -- `src/utils/markdown/renderRsc.tsx` -- `src/utils/markdown/processor.rsc.tsx` -- `src/components/markdown/renderCodeBlock.server.tsx` - -Changes: - -- Cache rendered docs artifacts, not just raw GitHub files. -- Persist `title`, `description`, `headings`, and rendered output keyed by repo, ref, docs root, and path. -- Reuse existing docs artifact cache infra instead of adding a second caching path. -- Make docs requests mostly cache hits unless the source changed. - -Expected win: - -- Better docs TTFB, less server CPU, fewer repeated markdown and Shiki passes. - -### 4. Docs layout mount discipline - -Targets: - -- `src/components/DocsLayout.tsx` -- `src/components/RightRail.tsx` -- `src/components/RecentPostsWidget.tsx` - -Changes: - -- Do not mount mobile docs menu on desktop. -- Do not mount desktop docs menu on mobile. -- Do not mount right rail when hidden by breakpoint. -- Gate animated partner strip work by actual viewport and reduced-motion preference. -- Ensure hidden rails do not issue queries or observers. - -Expected win: - -- Lower docs runtime cost, especially on mobile. - -### 5. Remove anonymous auth work from docs and landing - -Targets: - -- `src/components/FrameworkSelect.tsx` -- `src/hooks/useCurrentUser.ts` -- `src/components/SearchModal.tsx` -- `src/components/NavbarAuthControls.tsx` - -Changes: - -- Make framework preference local-first for anonymous users. -- Only sync framework preference to server when user state is already known. -- Avoid triggering `getCurrentUser` on docs and landing pages just to resolve a preference. -- Audit other shell components for accidental auth fetches during anonymous browsing. - -Expected win: - -- Fewer unnecessary client requests, cleaner anonymous docs navigation. - -### 6. Shared shell cleanup - -Targets: - -- `src/routes/__root.tsx` -- `src/router.tsx` -- `src/components/Navbar.tsx` -- `src/components/markdown/MarkdownContent.tsx` - -Changes: - -- Verify why some intended dynamic imports are not splitting effectively. -- Trim eager shell work around Sentry boot where possible. -- Fix `MarkdownContent` so `CopyPageDropdown` only loads on real interaction. -- Review navbar asset duplication and avoid eager light/dark image duplication where possible. - -Expected win: - -- Smaller app shell, less global cost paid by every route. - -## Suggested Implementation Order - -1. Fix obviously accidental eager work. -2. Make docs layout mount only what is visible. -3. Remove anonymous auth fetches from docs and landing flows. -4. Add dedicated library landing shell and remove docs-config from landing critical path. -5. Move homepage content and stats to server-first data flows and split below-the-fold sections. -6. Add rendered docs artifact caching. -7. Rebuild and compare chunks, request timings, and interaction cost. - -## PR Breakdown - -### PR 1 - -- Fix `MarkdownContent` eager copy-dropdown load -- Stop hidden docs rails and menus from mounting -- Gate mobile partner strip animation correctly - -### PR 2 - -- Remove anonymous auth fetches from framework selection and related docs shell code - -### PR 3 - -- Add `LibraryLandingLayout` -- Remove `DocsLayout` and docs config dependency from landing critical path - -### PR 4 - -- Split homepage below the fold -- Server-render recent posts and stats -- Tighten app-starter deferral - -### PR 5 - -- Cache rendered docs artifacts -- Measure docs TTFB and server CPU improvement - -### PR 6 - -- Shared shell follow-up: Sentry boot, navbar assets, remaining bundle outliers - -## Verification - -- Run `pnpm build` after each major phase. -- Track homepage, a representative library landing page, and a representative docs page. -- Compare: -- route chunk size -- app shell size -- docs TTFB -- number of client requests on first load -- whether anonymous docs visits trigger user/auth requests -- smoke-check desktop and mobile docs navigation - -## Notes - -- `LazyLandingCommunitySection` and `LazySponsorSection` already use the right pattern. Reuse that pattern more aggressively. -- `StackBlitzEmbed` is already `loading="lazy"`, but a poster-plus-click model may still be worth it for landing pages. -- Do not spend time micro-optimizing heading observers or tiny docs chunks before fixing the homepage and landing-page architecture. diff --git a/docs/ssr-rsc-migration-findings.md b/docs/ssr-rsc-migration-findings.md new file mode 100644 index 000000000..bf5088bd5 --- /dev/null +++ b/docs/ssr-rsc-migration-findings.md @@ -0,0 +1,106 @@ +# SSR + Hydrate vs RSC findings + +Date: 2026-06-20 + +Scope: local production builds of tanstack.com, with Redact enabled for both variants. The RSC baseline came from the current HEAD before the SSR migration work. The SSR variant is this worktree after removing RSC rendering and adding targeted `Hydrate` timing for below-fold media-heavy sections. + +## Current conclusion + +SSR + normal hydration + targeted `Hydrate` timing is the stronger direction for tanstack.com. + +RSC reduced some client-side rendering responsibility, but it did so by sending rendered payloads, Flight data, extra server-function surfaces, and a lot of architecture-specific indirection. Once SSR is paired with simple visible hydration gates for below-fold media/query-heavy sections, it matches or beats the RSC baseline on first-load production measurements and is smaller during long sessions for docs, examples, and blog navigation. + +The important correction from the first SSR pass: plain SSR without timing gates can eagerly schedule too much below-fold media. Lighthouse exposed this immediately on home and Query landing. Adding `Hydrate` around home social proof, home community, landing community, and sponsor pack sections fixed that without bringing back the RSC pipeline. + +## Architecture delta + +- RSC disabled in `vite.config.ts`; `@vitejs/plugin-rsc` removed. +- Redact is always enabled for this matrix. +- Markdown/docs/blog now move raw markdown/source data through normal server functions, then render locally with `@tanstack/markdown` and `@tanstack/highlight`. +- Deleted RSC markdown/code rendering files, RSC landing-code-example server function, and RSC heading context. +- Landing code examples are static route/component data, not rendered RSC payloads. +- Targeted `Hydrate` timing remains only as byte scheduling for below-fold sections. + +Code size/complexity: + +| Metric | Result | +| ---------------------------- | -----------------------------------------------: | +| Source diff | 113 files, +920 / -3946 | +| Current SSR client dist | 92,258.3 KB | +| RSC baseline client dist | 92,687.6 KB | +| Remaining RSC references | none | +| Remaining `use client` files | `CopyPageDropdown.tsx`, `CopyMarkdownButton.tsx` | + +## First-load HTML + JS/CSS + +Production servers, local Node, `.env.local` copied from the main checkout. This table counts HTML plus scheduled local JS/CSS assets. It excludes images/fonts because those are better captured by Lighthouse byte weight. + +| Page | RSC total gzip | SSR + Hydrate total gzip | Delta | RSC HTML gzip | SSR HTML gzip | Asset gzip delta | +| ---------------------------------------------------- | -------------: | -----------------------: | -------: | ------------: | ------------: | ---------------: | +| `/` | 589.3 KB | 586.7 KB | -2.6 KB | 88.2 KB | 36.1 KB | +49.5 KB | +| `/query/latest` | 609.5 KB | 592.6 KB | -16.9 KB | 106.9 KB | 47.9 KB | +42.2 KB | +| `/form/latest` | 593.7 KB | 579.9 KB | -13.8 KB | 95.0 KB | 39.0 KB | +42.2 KB | +| `/router/latest` | 593.5 KB | 594.3 KB | +0.8 KB | 89.7 KB | 36.0 KB | +54.6 KB | +| `/router/latest/docs/overview` | 582.7 KB | 568.8 KB | -13.8 KB | 91.4 KB | 37.7 KB | +39.9 KB | +| `/router/latest/docs/framework/react/examples/basic` | 601.2 KB | 585.9 KB | -15.3 KB | 97.1 KB | 37.5 KB | +44.3 KB | +| `/blog/tanstack-table-v9-typescript-performance` | 596.2 KB | 585.4 KB | -10.8 KB | 106.7 KB | 45.3 KB | +50.6 KB | + +Interpretation: SSR cuts roughly 52-61 KB gzip from HTML on every tested page. It adds 40-55 KB gzip of JS/CSS because the client now owns markdown/code rendering. Net first-load gzip is better on every tested page except Router landing, where it is effectively flat. + +## Long-session navigation payloads + +These are direct `_serverFn` payloads with Start's real Seroval encoding. This is the cleanest test of the theory that long sessions should favor SSR because the client already has the shared rendering logic. + +| Payload | RSC gzip | SSR gzip | Delta | +| ---------------------------- | -------: | -------: | ------: | +| Home recent posts | 0.7 KB | 0.7 KB | 0.0 KB | +| Router docs config | 2.2 KB | 2.2 KB | 0.0 KB | +| Query landing code example | 4.6 KB | 0.0 KB | -4.6 KB | +| Router docs overview content | 5.2 KB | 3.7 KB | -1.5 KB | +| Router example directory | 0.6 KB | 0.6 KB | 0.0 KB | +| Router example initial file | 5.6 KB | 1.5 KB | -4.1 KB | +| Heavy blog post content | 15.0 KB | 9.4 KB | -5.6 KB | + +The RSC payloads contain `contentRsc` and rendered code markers. The SSR payloads contain raw markdown/source data only. Shared config and normal JSON data are identical between variants. + +## Lighthouse desktop + +Lighthouse 13.4.0, local Chrome, desktop preset, production servers. Treat these as directional local lab numbers, not final PSI numbers. + +| Page | RSC score | SSR + Hydrate score | RSC FCP | SSR FCP | RSC LCP | SSR LCP | RSC CLS | SSR CLS | RSC byte weight | SSR byte weight | +| ------------------------------------------------ | --------: | ------------------: | ------: | ------: | ------: | ------: | ------: | ------: | --------------: | --------------: | +| `/` | 67 | 72 | 2245 ms | 2288 ms | 3305 ms | 2728 ms | 0.000 | 0.000 | 6493.3 KB | 3253.9 KB | +| `/query/latest` | 72 | 73 | 2442 ms | 2362 ms | 2562 ms | 2462 ms | 0.000 | 0.000 | 3190.4 KB | 2625.3 KB | +| `/router/latest/docs/overview` | 75 | 75 | 2202 ms | 2202 ms | 2422 ms | 2382 ms | 0.000 | 0.000 | 3493.3 KB | 2642.5 KB | +| `/blog/tanstack-table-v9-typescript-performance` | 70 | 75 | 2321 ms | 2202 ms | 2421 ms | 2342 ms | 0.116 | 0.000 | 4197.5 KB | 2776.2 KB | + +The pre-Hydrate SSR pass was misleading: home was 18,670.7 KB byte weight and Query was 11,562.6 KB because recent blog headers, partner assets, sponsor data, and GitHub avatars were eagerly scheduled. The current SSR + Hydrate pass removes that regression. + +## Hydration/navigation smoke + +Production SSR server on a clean port: + +- Loaded `/router/latest`. +- Clicked the real `/router/latest/docs/overview` link in the browser. +- URL and title updated to the docs overview page. +- Clean server log showed no document `GET /router/latest/docs/overview`; it showed only server-function requests for route data. +- Browser console warnings/errors were empty. + +That does not replace a full navigation test suite, but it specifically checks the earlier concern that hydration mismatches were causing client navigations to degrade into hard navigations. + +## Caveats + +- These are local production numbers, not hosted PageSpeed Insights results. +- The RSC baseline temp checkout needed the same `.env.local` as this worktree; without it, library/docs routes fell into fallback paths and measurements were invalid. +- Local Lighthouse includes image optimization behavior from the local Netlify image shim, so byte-weight direction is useful but production CDN numbers still need confirmation. +- Hydrate boundaries should stay targeted. They are useful for byte scheduling below the fold, not as a replacement for fixing hydration mismatches or CLS. + +## Decision + +Keep moving toward the simple SSR architecture: + +- no RSC build pipeline, +- raw markdown/source over server functions, +- tiny fast markdown/highlight libraries on the client path, +- targeted `Hydrate` timing for below-fold media/query work, +- deploy-preview PSI/Lighthouse before merging. diff --git a/package.json b/package.json index f5deece08..c6049874a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "@tanstack/ai-client": "^0.11.3", "@tanstack/ai-openai": "^0.9.5", "@tanstack/create": "^0.68.0", + "@tanstack/highlight": "workspace:*", + "@tanstack/markdown": "workspace:*", "@tanstack/pacer": "^0.21.1", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-pacer": "^0.22.1", @@ -71,7 +73,6 @@ "@uploadthing/react": "^7.3.3", "@visx/hierarchy": "^3.12.0", "@vitejs/plugin-react": "^6.0.1", - "@vitejs/plugin-rsc": "^0.5.24", "@webcontainer/api": "^1.6.1", "@xstate/react": "^6.1.0", "algoliasearch": "^5.50.0", @@ -83,8 +84,6 @@ "drizzle-orm": "^0.45.2", "encoding-sniffer": "^0.2.1", "gray-matter": "^4.0.3", - "hast-util-is-element": "^3.0.0", - "hast-util-to-string": "^3.0.1", "iconv-lite": "^0.7.2", "jszip": "^3.10.1", "lru-cache": "^11.2.7", @@ -100,25 +99,13 @@ "react-dom": "19.2.3", "react-easy-crop": "^5.5.7", "react-instantsearch": "^7.29.0", - "rehype-autolink-headings": "^7.1.0", - "rehype-callouts": "^2.1.2", - "rehype-parse": "^9.0.1", - "rehype-raw": "^7.0.0", - "rehype-react": "^8.0.0", - "rehype-slug": "^6.0.0", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", "remove-markdown": "^0.6.3", "resend": "^6.10.0", - "shiki": "^4.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tar-stream": "^3.1.8", "three": "^0.183.2", "troika-three-text": "^0.52.4", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", "uploadthing": "^7.7.4", "valibot": "^1.3.1", "vite-bundle-analyzer": "^1.3.6", @@ -129,7 +116,6 @@ "devDependencies": { "@content-collections/core": "^0.14.3", "@content-collections/vite": "^0.2.9", - "@shikijs/transformers": "^4.0.2", "@tanstack/devtools-vite": "^0.7.0", "@tanstack/react-devtools": "^0.10.5", "@tanstack/react-query-devtools": "^5.100.11", diff --git a/packages/highlight/package.json b/packages/highlight/package.json new file mode 100644 index 000000000..d67917662 --- /dev/null +++ b/packages/highlight/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tanstack/highlight", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./markdown": { + "types": "./src/markdown.ts", + "import": "./src/markdown.ts" + }, + "./rehype": { + "types": "./src/rehype.ts", + "import": "./src/rehype.ts" + }, + "./remark": { + "types": "./src/remark.ts", + "import": "./src/remark.ts" + }, + "./react": { + "types": "./src/react.ts", + "import": "./src/react.ts" + } + } +} diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts new file mode 100644 index 000000000..9f75ba026 --- /dev/null +++ b/packages/highlight/src/index.ts @@ -0,0 +1,574 @@ +export type HighlightLanguage = + | 'apache' + | 'css' + | 'diff' + | 'dockerfile' + | 'ejs' + | 'env' + | 'html' + | 'http' + | 'js' + | 'json' + | 'jsx' + | 'markdown' + | 'mermaid' + | 'nginx' + | 'plaintext' + | 'python' + | 'scheme' + | 'shell' + | 'sql' + | 'svelte' + | 'toml' + | 'ts' + | 'tsx' + | 'vue' + | 'yaml' + +export type HighlightOptions = { + lang?: string +} + +export type HighlightResult = { + code: string + html: string + lang: HighlightLanguage +} + +export type RenderCodeBlockOptions = HighlightOptions & { + title?: string +} + +export type RenderedCodeBlockData = { + copyText: string + htmlMarkup: string + lang: HighlightLanguage + title?: string +} + +export type HighlightTokenClass = + | 'attr' + | 'code-inline' + | 'command' + | 'comment' + | 'deleted' + | 'function' + | 'heading' + | 'inserted' + | 'keyword' + | 'link' + | 'literal' + | 'meta' + | 'number' + | 'operator' + | 'property' + | 'selector' + | 'string' + | 'tag' + | 'type' + | 'variable' + +export type HighlightToken = { + className?: HighlightTokenClass + value: string +} + +export type HighlightTokenResult = { + code: string + lang: HighlightLanguage + tokens: Array +} + +type Pattern = { + className: HighlightTokenClass + regex: RegExp + group?: number +} + +type TokenRange = { + start: number + end: number + className: HighlightTokenClass +} + +const supportedLanguages = [ + 'apache', + 'css', + 'diff', + 'dockerfile', + 'ejs', + 'env', + 'html', + 'http', + 'js', + 'json', + 'jsx', + 'markdown', + 'mermaid', + 'nginx', + 'plaintext', + 'python', + 'scheme', + 'shell', + 'sql', + 'svelte', + 'toml', + 'ts', + 'tsx', + 'vue', + 'yaml', +] as const satisfies ReadonlyArray + +const languageAliases: Record = { + '-->': 'plaintext', + 'angular-html': 'html', + 'angular-ts': 'ts', + bash: 'shell', + cjs: 'js', + cmd: 'shell', + console: 'shell', + dotenv: 'env', + htm: 'html', + javascript: 'js', + 'js-vue': 'js', + json5: 'json', + jsonc: 'json', + markdown: 'markdown', + md: 'markdown', + mjs: 'js', + sh: 'shell', + shell: 'shell', + text: 'plaintext', + ts: 'ts', + typescript: 'ts', + txt: 'plaintext', + xml: 'html', + yml: 'yaml', + zsh: 'shell', +} + +const jsKeywords = + 'as|async|await|break|case|catch|class|const|continue|default|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|interface|let|new|of|return|satisfies|switch|throw|try|type|typeof|var|while|yield' + +const scriptPatterns: Array = [ + { className: 'comment', regex: /\/\/[^\n]*|\/\*[\s\S]*?\*\//g }, + { + className: 'string', + regex: /`(?:\\[\s\S]|[^`\\])*`|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g, + }, + { className: 'function', regex: /@[A-Za-z_$][\w$]*/g }, + { className: 'keyword', regex: new RegExp(`\\b(?:${jsKeywords})\\b`, 'g') }, + { className: 'literal', regex: /\b(?:false|null|true|undefined)\b/g }, + { + className: 'function', + regex: /\bfunction\s+([A-Za-z_$][\w$]*)/g, + group: 1, + }, + { + className: 'function', + regex: /(^|[^.A-Za-z0-9_$])([A-Za-z_$][\w$]*)\s*(?=\()/g, + group: 2, + }, + { + className: 'type', + regex: + /\b(?:Array|Record|Promise|Readonly|Set|Map|string|number|boolean|unknown|never|void|any|[A-Z][A-Za-z0-9_$]*)\b/g, + }, + { className: 'property', regex: /\.([A-Za-z_$][\w$]*)/g, group: 1 }, +] + +const jsxLikePatterns: Array = [ + { className: 'tag', regex: /<\/?\s*([A-Za-z][\w.-]*)/g, group: 1 }, + { className: 'attr', regex: /\s([:@A-Za-z_][\w:.-]*)(?=\s*=)/g, group: 1 }, + ...scriptPatterns, +] + +const htmlLikePatterns: Array = [ + { className: 'comment', regex: //g }, + { className: 'string', regex: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g }, + { className: 'tag', regex: /<\/?\s*([A-Za-z][\w:.-]*)/g, group: 1 }, + { className: 'attr', regex: /\s([:@A-Za-z_][\w:.-]*)(?=\s*=)/g, group: 1 }, +] + +const patternsByLanguage: Partial>> = { + apache: [ + { className: 'tag', regex: /<\/?\s*([A-Za-z][\w.-]*)/g, group: 1 }, + { + className: 'keyword', + regex: /\b(?:DocumentRoot|ServerName|VirtualHost)\b/g, + }, + { + className: 'string', + regex: /\b(?:[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\*:\d+)\b/g, + }, + ], + css: [ + { className: 'comment', regex: /\/\*[\s\S]*?\*\//g }, + { className: 'string', regex: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g }, + { className: 'variable', regex: /--[A-Za-z0-9_-]+/g }, + { className: 'selector', regex: /(^|\n)\s*([^{}\n]+)(?=\s*\{)/g, group: 2 }, + { className: 'property', regex: /[A-Za-z-]+(?=\s*:)/g }, + { className: 'function', regex: /\b[A-Za-z-]+(?=\()/g }, + ], + diff: [ + { className: 'meta', regex: /^@@.*$/gm }, + { className: 'deleted', regex: /^-.*$/gm }, + { className: 'inserted', regex: /^\+.*$/gm }, + ], + dockerfile: [ + { className: 'comment', regex: /^#.*$/gm }, + { + className: 'keyword', + regex: + /^\s*(?:ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|LABEL|RUN|USER|VOLUME|WORKDIR)\b/gim, + }, + { className: 'string', regex: /\b[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+\b/g }, + { className: 'command', regex: /\b(?:bun|npm|pnpm|yarn|node)\b/g }, + ], + ejs: [ + ...htmlLikePatterns, + { className: 'keyword', regex: /\b(?:else|for|if|include|while)\b/g }, + { className: 'property', regex: /\.([A-Za-z_$][\w$]*)/g, group: 1 }, + ], + env: [ + { className: 'comment', regex: /^#.*$/gm }, + { className: 'property', regex: /^[A-Za-z_][A-Za-z0-9_]*(?==)/gm }, + { className: 'string', regex: /=(.*)$/gm, group: 1 }, + ], + html: htmlLikePatterns, + http: [ + { className: 'keyword', regex: /^(?:DELETE|GET|PATCH|POST|PUT)\b/gm }, + { className: 'property', regex: /^[A-Za-z-]+(?=:)/gm }, + { className: 'string', regex: /(?:\/[^\s]*|[A-Za-z-]+\/[A-Za-z0-9.+-]+)/g }, + ], + js: scriptPatterns, + json: [ + { className: 'comment', regex: /\/\/[^\n]*|\/\*[\s\S]*?\*\//g }, + { className: 'property', regex: /"((?:\\.|[^"\\])*)"\s*:/g, group: 1 }, + { className: 'string', regex: /"(?:\\.|[^"\\])*"/g }, + { className: 'literal', regex: /\b(?:false|null|true)\b/g }, + { className: 'number', regex: /-?\b\d+(?:\.\d+)?\b/g }, + ], + jsx: jsxLikePatterns, + markdown: [ + { className: 'heading', regex: /^#{1,6}\s.*$/gm }, + { className: 'code-inline', regex: /`[^`\n]+`/g }, + { className: 'link', regex: /\[[^\]]+\]\([^)]+\)/g }, + ], + mermaid: [ + { + className: 'keyword', + regex: + /^\s*(?:classDiagram|flowchart|graph|journey|sequenceDiagram|stateDiagram-v2)\b/gm, + }, + { className: 'operator', regex: /-->|---|==>|-.->/g }, + ], + nginx: [ + { className: 'comment', regex: /#.*$/gm }, + { + className: 'keyword', + regex: + /\b(?:events|http|listen|location|proxy_pass|server|server_name|upstream)\b/g, + }, + { className: 'number', regex: /\b\d+\b/g }, + { className: 'string', regex: /(?:\/[^\s;{}]*|https?:\/\/[^\s;{}]+)/g }, + ], + python: [ + { className: 'comment', regex: /#[^\n]*/g }, + { className: 'string', regex: /f?"(?:\\.|[^"\\])*"|f?'(?:\\.|[^'\\])*'/g }, + { + className: 'keyword', + regex: + /\b(?:as|class|def|elif|else|except|for|from|if|import|in|lambda|pass|return|try|while|with|yield)\b/g, + }, + { className: 'function', regex: /\bdef\s+([A-Za-z_][\w]*)/g, group: 1 }, + { className: 'type', regex: /\b(?:bool|dict|float|int|list|str|tuple)\b/g }, + ], + scheme: [ + { className: 'string', regex: /"(?:\\.|[^"\\])*"/g }, + { className: 'keyword', regex: /\b(?:define|lambda|let|if|cond|else)\b/g }, + { + className: 'function', + regex: /\(define\s+\(([A-Za-z_+\-*/<>=!?][\w+\-*/<>=!?]*)/g, + group: 1, + }, + ], + shell: [ + { className: 'comment', regex: /^#!.*$|#.*$/gm }, + { className: 'string', regex: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g }, + { + className: 'variable', + regex: /\$[A-Za-z_][A-Za-z0-9_]*|\b[A-Z_][A-Z0-9_]*(?==)/g, + }, + { + className: 'keyword', + regex: + /\b(?:case|do|done|elif|else|esac|export|fi|for|function|if|in|then|while)\b/g, + }, + { + className: 'command', + regex: /(^|\n)\s*([A-Za-z0-9_./-]+)(?=\s|$)/g, + group: 2, + }, + ], + sql: [ + { className: 'comment', regex: /--[^\n]*|\/\*[\s\S]*?\*\//g }, + { className: 'string', regex: /'(?:''|[^'])*'/g }, + { + className: 'keyword', + regex: + /\b(?:and|as|by|delete|from|group|insert|join|limit|order|select|set|update|values|where)\b/gi, + }, + { className: 'literal', regex: /\b(?:false|null|true)\b/gi }, + ], + svelte: [...htmlLikePatterns, ...scriptPatterns], + toml: [ + { className: 'heading', regex: /^\s*\[[^\]]+\]/gm }, + { className: 'property', regex: /^[A-Za-z0-9_.-]+(?=\s*=)/gm }, + { className: 'string', regex: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g }, + { className: 'literal', regex: /\b(?:false|true|\d+(?:\.\d+)?)\b/g }, + ], + ts: scriptPatterns, + tsx: jsxLikePatterns, + vue: [...htmlLikePatterns, ...scriptPatterns], + yaml: [ + { className: 'comment', regex: /#.*$/gm }, + { className: 'property', regex: /^[ \t-]*[A-Za-z0-9_.-]+(?=\s*:)/gm }, + { className: 'string', regex: /:\s*([^\n#]+)$/gm, group: 1 }, + ], +} + +export function normalizeLanguage(lang?: string): HighlightLanguage { + const normalized = (lang || 'plaintext').trim().toLowerCase() + + if (isSupportedLanguage(normalized)) { + return normalized + } + + return languageAliases[normalized] || 'plaintext' +} + +export function listLanguages(): Array { + return [...supportedLanguages] +} + +export function highlight( + code: string, + options: HighlightOptions = {}, +): HighlightResult { + const result = tokenize(code, options) + const innerHtml = renderTokensToHtml(result.tokens) + + return { + code, + html: `
${innerHtml}
`, + lang: result.lang, + } +} + +export function highlightToHtml(code: string, options: HighlightOptions = {}) { + return highlight(code, options).html +} + +export function tokenize( + code: string, + options: HighlightOptions = {}, +): HighlightTokenResult { + const lang = normalizeLanguage(options.lang) + + return { + code, + lang, + tokens: + lang === 'plaintext' + ? [{ value: code }] + : collectTokens( + code, + collectRanges(code, patternsByLanguage[lang] || []), + ), + } +} + +export function renderCodeBlockData({ + code, + lang, + title, +}: { + code: string + lang?: string + title?: string +}): RenderedCodeBlockData { + const copyText = code.trimEnd() + + return { + copyText, + htmlMarkup: highlightToHtml(copyText, { lang }), + lang: normalizeLanguage(lang), + title, + } +} + +export function createThemeCss() { + return `:root { + --th-token: inherit; + --th-keyword: #cf222e; + --th-string: #0a7f64; + --th-comment: #6e7781; + --th-function: #8250df; + --th-type: #953800; + --th-property: #0550ae; + --th-tag: #116329; + --th-attr: #0550ae; + --th-literal: #0550ae; + --th-number: #0550ae; + --th-variable: #953800; + --th-operator: #8250df; + --th-inserted: #1a7f37; + --th-deleted: #cf222e; + --th-meta: #57606a; + --th-heading: #0550ae; + --th-link: #0969da; + --th-code-inline: #953800; + --th-selector: #116329; + --th-command: #8250df; +} + +.dark { + --th-keyword: #ff7b72; + --th-string: #a5d6ff; + --th-comment: #8b949e; + --th-function: #d2a8ff; + --th-type: #ffa657; + --th-property: #79c0ff; + --th-tag: #7ee787; + --th-attr: #79c0ff; + --th-literal: #79c0ff; + --th-number: #79c0ff; + --th-variable: #ffa657; + --th-operator: #d2a8ff; + --th-inserted: #7ee787; + --th-deleted: #ff7b72; + --th-meta: #8b949e; + --th-heading: #79c0ff; + --th-link: #58a6ff; + --th-code-inline: #ffa657; + --th-selector: #7ee787; + --th-command: #d2a8ff; +} + +.th-token { color: var(--th-token); } +.th-keyword { color: var(--th-keyword); } +.th-string { color: var(--th-string); } +.th-comment { color: var(--th-comment); } +.th-function { color: var(--th-function); } +.th-type { color: var(--th-type); } +.th-property { color: var(--th-property); } +.th-tag { color: var(--th-tag); } +.th-attr { color: var(--th-attr); } +.th-literal { color: var(--th-literal); } +.th-number { color: var(--th-number); } +.th-variable { color: var(--th-variable); } +.th-operator { color: var(--th-operator); } +.th-inserted { color: var(--th-inserted); } +.th-deleted { color: var(--th-deleted); } +.th-meta { color: var(--th-meta); } +.th-heading { color: var(--th-heading); } +.th-link { color: var(--th-link); } +.th-code-inline { color: var(--th-code-inline); } +.th-selector { color: var(--th-selector); } +.th-command { color: var(--th-command); }` +} + +function collectTokens(code: string, ranges: Array) { + const tokens: Array = [] + let index = 0 + + for (const range of ranges) { + if (range.start > index) { + tokens.push({ value: code.slice(index, range.start) }) + } + + tokens.push({ + className: range.className, + value: code.slice(range.start, range.end), + }) + index = range.end + } + + if (index < code.length) { + tokens.push({ value: code.slice(index) }) + } + + return tokens +} + +function renderTokensToHtml(tokens: Array) { + let html = '' + + for (const token of tokens) { + const value = escapeHtml(token.value) + html += token.className + ? `${value}` + : value + } + + return html +} + +function collectRanges(code: string, patterns: Array) { + const ranges: Array = [] + const occupied = new Uint8Array(code.length) + + for (const pattern of patterns) { + const regex = cloneRegex(pattern.regex) + let match: RegExpExecArray | null + + while ((match = regex.exec(code))) { + const value = pattern.group ? match[pattern.group] : match[0] + if (!value) { + if (match[0].length === 0) regex.lastIndex++ + continue + } + + const fullStart = match.index + const groupOffset = pattern.group ? match[0].indexOf(value) : 0 + const start = fullStart + groupOffset + const end = start + value.length + + if (start === end || hasOverlap(occupied, start, end)) continue + + ranges.push({ start, end, className: pattern.className }) + occupied.fill(1, start, end) + } + } + + return ranges.sort((a, b) => a.start - b.start || a.end - b.end) +} + +function hasOverlap(occupied: Uint8Array, start: number, end: number) { + for (let index = start; index < end; index++) { + if (occupied[index]) return true + } + + return false +} + +function cloneRegex(regex: RegExp) { + return new RegExp( + regex.source, + regex.flags.includes('g') ? regex.flags : `${regex.flags}g`, + ) +} + +function isSupportedLanguage(lang: string): lang is HighlightLanguage { + return (supportedLanguages as ReadonlyArray).includes(lang) +} + +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/packages/highlight/src/markdown.ts b/packages/highlight/src/markdown.ts new file mode 100644 index 000000000..5f019eafe --- /dev/null +++ b/packages/highlight/src/markdown.ts @@ -0,0 +1,117 @@ +import { + renderCodeBlockData, + tokenize, + type HighlightLanguage, + type HighlightToken, + type RenderedCodeBlockData, +} from './index' + +export type CodeFenceInput = { + code: string + lang?: string | null + meta?: string | null + title?: string | null +} + +export type HighlightedCodeFence = RenderedCodeBlockData & { + tokens: Array +} + +export type HastText = { + type: 'text' + value: string +} + +export type HastElement = { + type: 'element' + tagName: string + properties?: Record + children: Array +} + +export function getCodeFenceTitle(meta?: string | null) { + if (!meta) return undefined + + const match = meta.match( + /\b(?:title|filename|file|name)=("[^"]*"|'[^']*'|[^\s}]+)/, + ) + if (!match) return undefined + + const value = match[1] + const unquoted = + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ? value.slice(1, -1) + : value + + return unquoted.trim() || undefined +} + +export function renderCodeFence({ + code, + lang, + meta, + title, +}: CodeFenceInput): HighlightedCodeFence { + const resolvedTitle = title || getCodeFenceTitle(meta) + const rendered = renderCodeBlockData({ + code, + lang: lang || undefined, + title: resolvedTitle || undefined, + }) + const tokenized = tokenize(rendered.copyText, { lang: lang || undefined }) + + return { + ...rendered, + tokens: tokenized.tokens, + } +} + +export function codeFenceToHast(input: CodeFenceInput): HastElement { + const rendered = renderCodeFence(input) + + return tokensToHast(rendered.tokens, rendered.lang) +} + +export function tokensToHast( + tokens: Array, + lang: HighlightLanguage, +): HastElement { + return { + type: 'element', + tagName: 'pre', + properties: { + className: ['th-code', `th-code--${lang}`], + dataLanguage: lang, + }, + children: [ + { + type: 'element', + tagName: 'code', + properties: {}, + children: tokens.map((token) => { + if (!token.className) { + return { + type: 'text', + value: token.value, + } + } + + return { + type: 'element', + tagName: 'span', + properties: { + className: ['th-token', `th-${token.className}`], + }, + children: [ + { + type: 'text', + value: token.value, + }, + ], + } + }), + }, + ], + } +} diff --git a/packages/highlight/src/react.ts b/packages/highlight/src/react.ts new file mode 100644 index 000000000..7cefbbd82 --- /dev/null +++ b/packages/highlight/src/react.ts @@ -0,0 +1,36 @@ +import { renderCodeBlockData, type RenderedCodeBlockData } from './index' + +export type HighlightedCodeBlockProps = RenderedCodeBlockData & { + className?: string + isEmbedded?: boolean + showTypeCopyButton?: boolean + style?: unknown +} + +export type CreateHighlightedCodeBlockPropsOptions = { + className?: string + code: string + isEmbedded?: boolean + lang?: string + showTypeCopyButton?: boolean + style?: unknown + title?: string +} + +export function createHighlightedCodeBlockProps({ + className, + code, + isEmbedded, + lang, + showTypeCopyButton, + style, + title, +}: CreateHighlightedCodeBlockPropsOptions): HighlightedCodeBlockProps { + return { + ...renderCodeBlockData({ code, lang, title }), + className, + isEmbedded, + showTypeCopyButton, + style, + } +} diff --git a/packages/highlight/src/rehype.ts b/packages/highlight/src/rehype.ts new file mode 100644 index 000000000..950f13565 --- /dev/null +++ b/packages/highlight/src/rehype.ts @@ -0,0 +1,103 @@ +import { codeFenceToHast, type HastElement, type HastText } from './markdown' + +export type RehypeHighlightOptions = { + getTitle?: (node: HastElement) => string | undefined +} + +type HastNode = HastElement | HastText | RawNode | UnknownNode + +type RawNode = { + type: 'raw' + value: string +} + +type UnknownNode = { + children?: Array + properties?: Record + tagName?: string + type?: string + value?: unknown + [key: string]: unknown +} + +export function rehypeHighlightCodeBlocks( + options: RehypeHighlightOptions = {}, +) { + return function transformer(tree: UnknownNode) { + replacePreCodeNodes(tree, options) + } +} + +export function rehypePreCodeToHast( + node: HastElement, + options: RehypeHighlightOptions = {}, +) { + const code = getCodeChild(node) + if (!code) return undefined + + return codeFenceToHast({ + code: collectText(code).trimEnd(), + lang: getLanguage(code), + title: options.getTitle?.(node), + }) +} + +function replacePreCodeNodes( + node: UnknownNode, + options: RehypeHighlightOptions, +) { + const children = node.children + if (!children) return + + for (let index = 0; index < children.length; index++) { + const child = children[index] + + if (isElement(child) && child.tagName === 'pre') { + const highlighted = rehypePreCodeToHast(child, options) + if (highlighted) { + children[index] = highlighted + } + continue + } + + replacePreCodeNodes(child as UnknownNode, options) + } +} + +function getCodeChild(node: HastElement) { + return node.children.find( + (child): child is HastElement => + isElement(child) && child.tagName === 'code', + ) +} + +function getLanguage(node: HastElement) { + const className = node.properties?.className + const classes = Array.isArray(className) + ? className + : typeof className === 'string' + ? className.split(/\s+/) + : [] + const languageClass = classes.find( + (value): value is string => + typeof value === 'string' && value.startsWith('language-'), + ) + + return languageClass?.slice('language-'.length) +} + +function collectText(node: HastNode): string { + if (node.type === 'text' && typeof node.value === 'string') { + return node.value + } + + if ('children' in node && Array.isArray(node.children)) { + return node.children.map((child) => collectText(child)).join('') + } + + return '' +} + +function isElement(node: HastNode): node is HastElement { + return node.type === 'element' && typeof node.tagName === 'string' +} diff --git a/packages/highlight/src/remark.ts b/packages/highlight/src/remark.ts new file mode 100644 index 000000000..3ae7e20b1 --- /dev/null +++ b/packages/highlight/src/remark.ts @@ -0,0 +1,78 @@ +import { renderCodeFence, type CodeFenceInput } from './markdown' + +export type RemarkCodeNode = { + type: 'code' + value: string + lang?: string | null + meta?: string | null +} + +export type RemarkHtmlNode = { + type: 'html' + value: string + data?: Record +} + +export type RemarkHighlightOptions = { + getTitle?: (node: RemarkCodeNode) => string | undefined +} + +type UnknownNode = { + children?: Array + type?: string + [key: string]: unknown +} + +export function remarkCodeNodeToHtml( + node: RemarkCodeNode, + options: RemarkHighlightOptions = {}, +): RemarkHtmlNode { + const rendered = renderCodeFence({ + code: node.value, + lang: node.lang, + meta: node.meta, + title: options.getTitle?.(node), + } satisfies CodeFenceInput) + + return { + type: 'html', + value: rendered.htmlMarkup, + data: { + tanstackHighlight: { + copyText: rendered.copyText, + lang: rendered.lang, + title: rendered.title, + }, + }, + } +} + +export function remarkHighlightCodeBlocks( + options: RemarkHighlightOptions = {}, +) { + return function transformer(tree: UnknownNode) { + replaceCodeNodes(tree, options) + } +} + +function replaceCodeNodes(node: UnknownNode, options: RemarkHighlightOptions) { + const children = node.children + if (!children) return + + for (let index = 0; index < children.length; index++) { + const child = children[index] + + if (isRemarkCodeNode(child)) { + children[index] = remarkCodeNodeToHtml(child, options) as UnknownNode + continue + } + + replaceCodeNodes(child, options) + } +} + +function isRemarkCodeNode( + node: UnknownNode, +): node is UnknownNode & RemarkCodeNode { + return node.type === 'code' && typeof node.value === 'string' +} diff --git a/packages/markdown/package.json b/packages/markdown/package.json new file mode 100644 index 000000000..28c94850f --- /dev/null +++ b/packages/markdown/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tanstack/markdown", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "peerDependencies": { + "react": ">=18" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./html": { + "types": "./src/html.ts", + "import": "./src/html.ts" + }, + "./parser": { + "types": "./src/parser.ts", + "import": "./src/parser.ts" + }, + "./react": { + "types": "./src/react.ts", + "import": "./src/react.ts" + }, + "./extensions/callouts": { + "types": "./src/extensions/callouts.ts", + "import": "./src/extensions/callouts.ts" + }, + "./extensions/comment-components": { + "types": "./src/extensions/comment-components.ts", + "import": "./src/extensions/comment-components.ts" + }, + "./extensions/docs": { + "types": "./src/extensions/docs.ts", + "import": "./src/extensions/docs.ts" + }, + "./extensions/framework": { + "types": "./src/extensions/framework.ts", + "import": "./src/extensions/framework.ts" + }, + "./extensions/headings": { + "types": "./src/extensions/headings.ts", + "import": "./src/extensions/headings.ts" + }, + "./extensions/tabs": { + "types": "./src/extensions/tabs.ts", + "import": "./src/extensions/tabs.ts" + } + } +} diff --git a/packages/markdown/src/extensions/callouts.ts b/packages/markdown/src/extensions/callouts.ts new file mode 100644 index 000000000..2a2db00f1 --- /dev/null +++ b/packages/markdown/src/extensions/callouts.ts @@ -0,0 +1,47 @@ +import type { CalloutNode, MarkdownExtension } from '../types.js' + +export function calloutsExtension(): MarkdownExtension { + return { + name: 'callouts', + parseBlock: parseCalloutBlock, + } +} + +export function parseCalloutBlock( + context: Parameters>[0], +): CalloutNode | undefined { + const first = context.lines[context.index] ?? '' + const match = first.match(/^ {0,3}>\s*\[!([A-Za-z]+)\](?:\s+(.*))?$/) + if (!match) return undefined + + const kind = match[1]!.toLowerCase() + const title = match[2]?.trim() || titleCase(kind) + const body: string[] = [] + let cursor = context.index + 1 + + while (cursor < context.lines.length) { + const line = context.lines[cursor] ?? '' + if (/^\s*$/.test(line)) { + body.push('') + cursor++ + continue + } + + const quoted = line.match(/^ {0,3}>\s?(.*)$/) + if (!quoted) break + body.push(quoted[1]!) + cursor++ + } + + context.consume(cursor - context.index) + return { + type: 'callout', + kind, + title, + children: context.parseBlocks(body.join('\n')), + } +} + +function titleCase(value: string) { + return value.slice(0, 1).toUpperCase() + value.slice(1).toLowerCase() +} diff --git a/packages/markdown/src/extensions/comment-components.ts b/packages/markdown/src/extensions/comment-components.ts new file mode 100644 index 000000000..9f186c17c --- /dev/null +++ b/packages/markdown/src/extensions/comment-components.ts @@ -0,0 +1,106 @@ +import type { ComponentNode, MarkdownExtension } from '../types.js' + +export interface CommentComponentOptions { + transformComponent?: (node: ComponentNode) => ComponentNode +} + +export interface ComponentComment { + block: boolean + name: string + attributes: Record +} + +export function commentComponentsExtension( + options: CommentComponentOptions = {}, +): MarkdownExtension { + return { + name: 'comment-components', + parseBlock: (context) => parseCommentComponentBlock(context, options), + } +} + +export function parseCommentComponentBlock( + context: Parameters>[0], + options: CommentComponentOptions = {}, +): ComponentNode | undefined { + const line = context.lines[context.index] ?? '' + const start = parseComponentComment(line) + if (!start) return undefined + + if (!start.block) { + context.consume(1) + return transform( + { + type: 'component', + name: start.name, + attributes: start.attributes, + children: [], + }, + options, + ) + } + + const body: string[] = [] + let cursor = context.index + 1 + let foundEnd = false + + while (cursor < context.lines.length) { + const candidate = context.lines[cursor] ?? '' + if (isEndComment(candidate, start.name)) { + foundEnd = true + cursor++ + break + } + body.push(candidate) + cursor++ + } + + context.consume(foundEnd ? cursor - context.index : 1) + return transform( + { + type: 'component', + name: start.name, + attributes: start.attributes, + children: foundEnd ? context.parseBlocks(body.join('\n')) : [], + }, + options, + ) +} + +export function parseComponentComment( + line: string, +): ComponentComment | undefined { + const match = line.match( + /^ {0,3}\s*$/, + ) + if (!match) return undefined + return { + block: Boolean(match[1]), + name: match[2]!.toLowerCase(), + attributes: parseAttributes(match[3] ?? ''), + } +} + +export function parseAttributes(value: string): Record { + const attrs: Record = {} + const regex = /([A-Za-z_][\w:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g + for (const match of value.matchAll(regex)) { + attrs[match[1]!] = match[2] ?? match[3] ?? match[4] ?? 'true' + } + return attrs +} + +function transform(node: ComponentNode, options: CommentComponentOptions) { + return options.transformComponent?.(node) ?? node +} + +function isEndComment(line: string, name: string): boolean { + return new RegExp( + `^ {0,3}\\s*$`, + 'i', + ).test(line) +} + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/markdown/src/extensions/docs.ts b/packages/markdown/src/extensions/docs.ts new file mode 100644 index 000000000..1a05eb71a --- /dev/null +++ b/packages/markdown/src/extensions/docs.ts @@ -0,0 +1,43 @@ +import type { ComponentNode, MarkdownExtension } from '../types.js' +import { calloutsExtension } from './callouts.js' +import { commentComponentsExtension } from './comment-components.js' +import { transformFrameworkComponent } from './framework.js' +import { + headingCollectionExtension, + type HeadingCollectionOptions, +} from './headings.js' +import { transformTabsComponent } from './tabs.js' + +export interface DocsMarkdownOptions { + collectHeadings?: boolean | HeadingCollectionOptions +} + +export function docsMarkdownExtensions( + options: DocsMarkdownOptions = {}, +): MarkdownExtension[] { + const extensions = [ + calloutsExtension(), + commentComponentsExtension({ + transformComponent: transformDocsComponent, + }), + ] + + if (options.collectHeadings !== false) { + extensions.push( + headingCollectionExtension( + typeof options.collectHeadings === 'object' + ? options.collectHeadings + : {}, + ), + ) + } + + return extensions +} + +export function transformDocsComponent(node: ComponentNode): ComponentNode { + const name = node.name.toLowerCase() + if (name === 'tabs') return transformTabsComponent(node) + if (name === 'framework') return transformFrameworkComponent(node) + return node +} diff --git a/packages/markdown/src/extensions/framework.ts b/packages/markdown/src/extensions/framework.ts new file mode 100644 index 000000000..4cf42afb6 --- /dev/null +++ b/packages/markdown/src/extensions/framework.ts @@ -0,0 +1,50 @@ +import type { BlockNode, ComponentNode } from '../types.js' +import { markFrameworkHeadings, splitByHeading } from './shared.js' + +export function transformFrameworkComponent( + node: ComponentNode, +): ComponentNode { + const sections = splitByHeading(node.children, 1) + if (!sections.length) return node + + const frameworks = sections.map((section) => section.name.toLowerCase()) + const panels = sections.map((section): ComponentNode => { + const framework = section.name.toLowerCase() + return { + type: 'component', + name: 'framework-panel', + tagName: 'md-framework-panel', + attributes: {}, + properties: { + 'data-framework': framework, + }, + children: markFrameworkHeadings(section.children, framework), + } + }) + + return { + ...node, + properties: { + ...(node.properties ?? {}), + 'data-available-frameworks': JSON.stringify(frameworks), + 'data-framework-meta': JSON.stringify({ + codeBlocksByFramework: Object.fromEntries( + sections.map((section) => [ + section.name.toLowerCase(), + section.children + .filter( + (child): child is Extract => + child.type === 'code', + ) + .map((child) => ({ + title: child.title ?? '', + code: child.value, + language: child.lang ?? 'plaintext', + })), + ]), + ), + }), + }, + children: panels, + } +} diff --git a/packages/markdown/src/extensions/headings.ts b/packages/markdown/src/extensions/headings.ts new file mode 100644 index 000000000..6d7b0f5d9 --- /dev/null +++ b/packages/markdown/src/extensions/headings.ts @@ -0,0 +1,99 @@ +import type { + BlockNode, + MarkdownDocument, + MarkdownExtension, + MarkdownHeading, +} from '../types.js' +import { plainText } from '../utils.js' + +export interface HeadingCollectionOptions { + skipComponentNames?: ReadonlySet | string[] +} + +export function headingCollectionExtension( + options: HeadingCollectionOptions = {}, +): MarkdownExtension { + return { + name: 'heading-collection', + transformDocument(document) { + return { + ...document, + headings: collectMarkdownHeadings(document, options), + } + }, + } +} + +export function collectMarkdownHeadings( + document: MarkdownDocument, + options: HeadingCollectionOptions = {}, +): MarkdownHeading[] { + const headings: MarkdownHeading[] = [] + const skip = new Set(options.skipComponentNames ?? ['tabs']) + collectFromBlocks(document.children, headings, undefined, false, skip) + return headings +} + +function collectFromBlocks( + blocks: BlockNode[], + headings: MarkdownHeading[], + framework: string | undefined, + insideSkippedComponent: boolean, + skipComponentNames: Set, +) { + for (const block of blocks) { + if (block.type === 'heading') { + if (!insideSkippedComponent && block.id) { + const heading: MarkdownHeading = { + id: block.id, + text: plainText(block.children), + level: block.depth, + } + const headingFramework = block.framework ?? framework + if (headingFramework) heading.framework = headingFramework + headings.push(heading) + } + continue + } + + if (block.type === 'list') { + for (const item of block.items) + collectFromBlocks( + item.children, + headings, + framework, + insideSkippedComponent, + skipComponentNames, + ) + continue + } + + if (block.type === 'blockquote' || block.type === 'callout') { + collectFromBlocks( + block.children, + headings, + framework, + insideSkippedComponent, + skipComponentNames, + ) + continue + } + + if (block.type === 'component') { + const name = block.name.toLowerCase() + const nextInsideSkippedComponent = + insideSkippedComponent || skipComponentNames.has(name) + const nextFramework = + block.tagName === 'md-framework-panel' + ? (block.properties?.['data-framework'] ?? framework) + : framework + collectFromBlocks( + block.children, + headings, + nextFramework, + nextInsideSkippedComponent, + skipComponentNames, + ) + } + } +} diff --git a/packages/markdown/src/extensions/shared.ts b/packages/markdown/src/extensions/shared.ts new file mode 100644 index 000000000..cddaec4e9 --- /dev/null +++ b/packages/markdown/src/extensions/shared.ts @@ -0,0 +1,123 @@ +import type { BlockNode, ComponentNode, HeadingNode } from '../types.js' +import { plainText } from '../utils.js' + +export interface HeadingSection { + id?: string + name: string + children: BlockNode[] +} + +export function blocksToText(blocks: BlockNode[]): string { + return blocks + .map((block) => { + switch (block.type) { + case 'paragraph': + case 'heading': + return plainText(block.children) + case 'code': + return block.value + case 'list': + return block.items + .map((item) => blocksToText(item.children)) + .join('\n') + case 'blockquote': + case 'callout': + case 'component': + return blocksToText(block.children) + case 'table': + return [block.header, ...block.rows] + .map((row) => row.map((cell) => plainText(cell.children)).join(' ')) + .join('\n') + default: + return '' + } + }) + .join('\n') +} + +export function splitByHeading( + children: BlockNode[], + forcedDepth?: number, +): HeadingSection[] { + const headings = children.filter( + (child): child is HeadingNode => child.type === 'heading', + ) + const depth = + forcedDepth ?? Math.min(...headings.map((heading) => heading.depth)) + if (!Number.isFinite(depth)) return [] + + const sections: HeadingSection[] = [] + let current: HeadingSection | undefined + + for (const child of children) { + if (child.type === 'heading' && child.depth === depth) { + current = { + name: plainText(child.children), + children: [], + } + if (child.id) current.id = child.id + sections.push(current) + continue + } + if (current) current.children.push(child) + } + + return sections +} + +export function slugify(value: string, fallback: string) { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 64) || fallback + ) +} + +export function markFrameworkHeadings( + blocks: BlockNode[], + framework: string, +): BlockNode[] { + return blocks.map((block) => { + if (block.type === 'heading' && block.depth > 1) { + return { + ...block, + framework, + } + } + + if (block.type === 'blockquote' || block.type === 'callout') { + return { + ...block, + children: markFrameworkHeadings(block.children, framework), + } + } + + if (block.type === 'list') { + return { + ...block, + items: block.items.map((item) => ({ + ...item, + children: markFrameworkHeadings(item.children, framework), + })), + } + } + + if (block.type === 'component') { + return { + ...block, + children: markFrameworkHeadings(block.children, framework), + } + } + + return block + }) +} + +export function getComponentName(node: ComponentNode) { + return node.name.toLowerCase() +} diff --git a/packages/markdown/src/extensions/tabs.ts b/packages/markdown/src/extensions/tabs.ts new file mode 100644 index 000000000..34ae94c41 --- /dev/null +++ b/packages/markdown/src/extensions/tabs.ts @@ -0,0 +1,185 @@ +import type { BlockNode, ComponentNode } from '../types.js' +import { blocksToText, slugify, splitByHeading } from './shared.js' + +const bundlers = ['vite', 'rsbuild'] as const + +export function transformTabsComponent(node: ComponentNode): ComponentNode { + const variant = node.attributes.variant?.toLowerCase() + + if (variant === 'files') return transformFileTabs(node) + if (variant === 'package-manager' || variant === 'package-managers') + return transformPackageManagerTabs(node) + if (variant === 'bundler') return transformBundlerTabs(node) + + return transformHeadingTabs(node) +} + +export function transformFileTabs(node: ComponentNode): ComponentNode { + const files = node.children.filter( + (child): child is Extract => + child.type === 'code', + ) + if (!files.length) return node + + const tabs = files.map((file, index) => ({ + slug: `file-${index}`, + name: file.title || file.file || 'Untitled', + })) + + return { + ...node, + properties: { + ...(node.properties ?? {}), + 'data-attributes': JSON.stringify({ tabs }), + 'data-files-meta': JSON.stringify({ + files: files.map((file) => ({ + title: file.title || file.file || 'Untitled', + code: file.value, + language: file.lang ?? 'plaintext', + })), + }), + }, + children: files.map( + (file, index): ComponentNode => ({ + type: 'component', + name: 'tab-panel', + tagName: 'md-tab-panel', + attributes: {}, + properties: { + 'data-tab-slug': `file-${index}`, + 'data-tab-index': String(index), + }, + children: [file], + }), + ), + } +} + +export function transformPackageManagerTabs( + node: ComponentNode, +): ComponentNode { + const packagesByFramework: Record = {} + + for (const line of blocksToText(node.children).split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const colon = trimmed.indexOf(':') + if (colon === -1) continue + const framework = trimmed.slice(0, colon).trim().toLowerCase() + const packages = trimmed + .slice(colon + 1) + .trim() + .split(/\s+/) + .filter(Boolean) + if (!framework || packages.length === 0) continue + packagesByFramework[framework] ??= [] + packagesByFramework[framework]!.push(packages) + } + + if (!Object.keys(packagesByFramework).length) return node + + return { + ...node, + properties: { + ...(node.properties ?? {}), + 'data-package-manager-meta': JSON.stringify({ + packagesByFramework, + mode: resolveInstallMode(node.attributes.mode), + }), + }, + children: [], + } +} + +export function transformBundlerTabs(node: ComponentNode): ComponentNode { + const sections = splitByHeading(node.children) + const selected = sections.filter((section) => + isBundler(section.name.toLowerCase()), + ) + if (!selected.length) return node + + const tabs = bundlers + .map((bundler) => + selected.find((section) => section.name.toLowerCase() === bundler), + ) + .filter((section): section is NonNullable => + Boolean(section), + ) + .map((section) => ({ + slug: section.name.toLowerCase(), + name: section.name.toLowerCase(), + })) + + return { + ...node, + properties: { + ...(node.properties ?? {}), + 'data-attributes': JSON.stringify({ tabs }), + 'data-bundler-meta': JSON.stringify({ + bundlers: tabs.map((tab) => tab.slug), + }), + }, + children: tabs.map((tab, index): ComponentNode => { + const section = selected.find( + (section) => section.name.toLowerCase() === tab.slug, + )! + return { + type: 'component', + name: 'tab-panel', + tagName: 'md-tab-panel', + attributes: {}, + properties: { + 'data-tab-slug': tab.slug, + 'data-tab-index': String(index), + 'data-content': + section.children.length === 1 && + section.children[0]?.type === 'code' + ? 'code-only' + : 'mixed', + }, + children: section.children, + } + }), + } +} + +export function transformHeadingTabs(node: ComponentNode): ComponentNode { + const sections = splitByHeading(node.children) + if (!sections.length) return node + + const tabs = sections.map((section, index) => ({ + slug: section.id || slugify(section.name, `tab-${index + 1}`), + name: section.name, + })) + + return { + ...node, + properties: { + ...(node.properties ?? {}), + 'data-attributes': JSON.stringify({ tabs }), + }, + children: sections.map( + (section, index): ComponentNode => ({ + type: 'component', + name: 'tab-panel', + tagName: 'md-tab-panel', + attributes: {}, + properties: { + 'data-tab-slug': tabs[index]?.slug ?? `tab-${index + 1}`, + 'data-tab-index': String(index), + }, + children: section.children, + }), + ), + } +} + +function resolveInstallMode(value: string | undefined): string { + const mode = value?.toLowerCase() + if (mode === 'dev-install' || mode === 'local-install') return mode + return 'install' +} + +function isBundler(value: string): value is (typeof bundlers)[number] { + return bundlers.some((bundler) => bundler === value) +} diff --git a/packages/markdown/src/html.ts b/packages/markdown/src/html.ts new file mode 100644 index 000000000..d2b767022 --- /dev/null +++ b/packages/markdown/src/html.ts @@ -0,0 +1,250 @@ +import { parseMarkdown } from './parser.js' +import type { + BlockNode, + ComponentNode, + InlineNode, + MarkdownDocument, + MarkdownInput, + RenderOptions, + TableCellNode, +} from './types.js' +import { escapeAttr, escapeHtml } from './utils.js' + +export function renderHtml( + input: MarkdownInput, + options: RenderOptions = {}, +): string { + const document = + typeof input === 'string' ? parseMarkdown(input, options) : input + return document.children.map((node) => renderBlock(node, options)).join('\n') +} + +export function renderBlock( + node: BlockNode, + options: RenderOptions = {}, +): string { + const extension = renderExtension(node, options) + if (extension !== undefined) return extension + + switch (node.type) { + case 'heading': { + const id = node.id ? ` id="${escapeAttr(node.id)}"` : '' + const framework = node.framework + ? ` data-framework="${escapeAttr(node.framework)}"` + : '' + return `${renderInlines(node.children, options)}${renderHeadingAnchor(node.id, options)}` + } + case 'paragraph': + return `

${renderInlines(node.children, options)}

` + case 'code': + return renderCodeBlock(node, options) + case 'list': { + const tag = node.ordered ? 'ol' : 'ul' + const start = + node.ordered && node.start && node.start !== 1 + ? ` start="${node.start}"` + : '' + const items = node.items + .map((item) => { + const task = + item.checked === undefined + ? '' + : ` ` + return `
  • ${task}${item.children.map((child) => renderBlock(child, options)).join('\n')}
  • ` + }) + .join('\n') + return `<${tag}${start}>\n${items}\n` + } + case 'blockquote': + return `
    \n${node.children.map((child) => renderBlock(child, options)).join('\n')}\n
    ` + case 'table': { + const header = `${node.header.map((cell, index) => renderTableCell('th', cell, node.align[index], options)).join('')}` + const body = `${node.rows + .map( + (row) => + `${row.map((cell, index) => renderTableCell('td', cell, node.align[index], options)).join('')}`, + ) + .join('')}` + return `${header}${body}
    ` + } + case 'thematicBreak': + return '
    ' + case 'html': + return options.allowHtml ? node.value : escapeHtml(node.value) + case 'callout': + return renderCallout(node, options) + case 'component': + return renderComponent(node, options) + } +} + +export function renderInline( + node: InlineNode, + options: RenderOptions = {}, +): string { + const extension = renderExtension(node, options) + if (extension !== undefined) return extension + + switch (node.type) { + case 'text': + return escapeHtml(node.value) + case 'inlineCode': + return `${escapeHtml(node.value)}` + case 'strong': + return `${renderInlines(node.children, options)}` + case 'emphasis': + return `${renderInlines(node.children, options)}` + case 'strike': + return `${renderInlines(node.children, options)}` + case 'link': + return `${renderInlines(node.children, options)}` + case 'image': + return `${escapeAttr(node.alt)}` + case 'break': + return '
    ' + case 'inlineElement': + return renderInlineElement(node, options) + case 'inlineHtml': + return options.allowHtml ? node.value : escapeHtml(node.value) + } +} + +export function renderDocument( + document: MarkdownDocument, + options: RenderOptions = {}, +): string { + return renderHtml(document, options) +} + +function renderInlines(nodes: InlineNode[], options: RenderOptions): string { + return nodes.map((node) => renderInline(node, options)).join('') +} + +function renderCodeBlock( + node: Extract, + options: RenderOptions, +): string { + const lang = node.lang ?? 'plaintext' + const codeAttrs = ` class="language-${escapeAttr(lang)}"` + const preAttrs = [ + 'class="tm-code"', + `data-lang="${escapeAttr(lang)}"`, + node.title ? `data-code-title="${escapeAttr(node.title)}"` : '', + node.file ? `data-filename="${escapeAttr(node.file)}"` : '', + node.framework ? `data-framework="${escapeAttr(node.framework)}"` : '', + ] + .filter(Boolean) + .join(' ') + const highlighter = options.highlighter + const highlightOptions = { + ...(node.highlightLines ? { highlightLines: node.highlightLines } : {}), + ...(options.codeLineNumbers !== undefined + ? { lineNumbers: options.codeLineNumbers } + : {}), + } + const html = highlighter + ? highlighter(node.value, lang, highlightOptions) + : escapeHtml(node.value) + + const pre = `
    ${html}
    ` + if (!node.title) return pre + + return `
    ${escapeHtml(node.title)}
    ${pre}
    ` +} + +function renderCallout( + node: Extract, + options: RenderOptions, +): string { + const kind = node.kind.toLowerCase() + const children = node.children + .map((child) => renderBlock(child, options)) + .join('\n') + return `

    ${escapeHtml(node.title)}

    ${children}
    ` +} + +function renderComponent(node: ComponentNode, options: RenderOptions): string { + const tag = node.tagName ?? 'md-comment-component' + const attrs = renderComponentAttrs(node) + const children = node.children + .map((child) => renderBlock(child, options)) + .join('\n') + return `<${tag}${attrs}>${children}` +} + +function renderTableCell( + tag: 'td' | 'th', + cell: TableCellNode, + align: 'left' | 'center' | 'right' | undefined, + options: RenderOptions, +): string { + const style = align ? ` style="text-align:${align}"` : '' + return `<${tag}${style}>${renderInlines(cell.children, options)}` +} + +function renderInlineElement( + node: Extract, + options: RenderOptions, +): string { + const attrs = renderAttributes(node.attributes) + if (node.void) return `<${node.tagName}${attrs}>` + return `<${node.tagName}${attrs}>${renderInlines(node.children, options)}` +} + +function renderAttributes(attributes: Record): string { + const entries = Object.entries(attributes) + if (!entries.length) return '' + return ` ${entries + .map(([key, value]) => + value ? `${key}="${escapeAttr(value)}"` : escapeAttr(key), + ) + .join(' ')}` +} + +function renderExtension( + node: BlockNode | InlineNode, + options: RenderOptions, +): string | undefined { + for (const extension of options.extensions ?? []) { + const rendered = extension.renderHtml?.(node, { + options, + renderBlock: (block) => renderBlock(block, options), + renderInline: (inline) => renderInline(inline, options), + }) + if (rendered !== undefined) return rendered + } + return undefined +} + +function renderHeadingAnchor( + id: string | undefined, + options: RenderOptions, +): string { + if (!id || !options.headingAnchors) return '' + + const anchorOptions = + typeof options.headingAnchors === 'object' ? options.headingAnchors : {} + const content = anchorOptions.content ?? '#' + const className = + anchorOptions.className ?? 'anchor-heading anchor-heading-link' + const ariaHidden = anchorOptions.ariaHidden ?? true + const tabIndex = anchorOptions.tabIndex ?? -1 + + return `${escapeHtml(content)}` +} + +function renderComponentAttrs(node: ComponentNode): string { + const props: Record = { + ...node.properties, + } + + if (!node.tagName) { + props['data-component'] = node.name + if (!props['data-attributes']) + props['data-attributes'] = JSON.stringify(node.attributes) + } + + const entries = Object.entries(props) + if (!entries.length) return '' + return ` ${entries.map(([key, value]) => `${key}="${escapeAttr(value)}"`).join(' ')}` +} diff --git a/packages/markdown/src/index.ts b/packages/markdown/src/index.ts new file mode 100644 index 000000000..5d2fcf2f4 --- /dev/null +++ b/packages/markdown/src/index.ts @@ -0,0 +1,9 @@ +export { + renderBlock, + renderDocument, + renderHtml, + renderInline, +} from './html.js' +export { parseInline } from './inline.js' +export { parseMarkdown } from './parser.js' +export type * from './types.js' diff --git a/packages/markdown/src/inline.ts b/packages/markdown/src/inline.ts new file mode 100644 index 000000000..0c2ab43f9 --- /dev/null +++ b/packages/markdown/src/inline.ts @@ -0,0 +1,485 @@ +import type { InlineNode, ParseOptions } from './types.js' +import { sanitizeUrl } from './utils.js' + +export function parseInline( + value: string, + options: ParseOptions = {}, +): InlineNode[] { + const nodes = parseInlineRaw(value, options) + let result = mergeText(nodes) + + for (const extension of options.extensions ?? []) { + result = extension.transformInline?.(result, { options }) ?? result + } + + return result +} + +function parseInlineRaw(value: string, options: ParseOptions): InlineNode[] { + const nodes: InlineNode[] = [] + let index = 0 + let text = '' + + const pushText = () => { + if (text) { + nodes.push({ type: 'text', value: text }) + text = '' + } + } + + while (index < value.length) { + const char = value[index]! + const next = value[index + 1] + + if (char === '\\') { + if (next === '\n') { + pushText() + nodes.push({ type: 'break' }) + index += 2 + continue + } + + if (next && /[\\`*_[\]{}()#+\-.!|~>]/.test(next)) { + text += next + index += 2 + continue + } + } + + if (char === '`') { + const tickCount = countRun(value, index, '`') + const close = findClosingRun(value, index + tickCount, '`', tickCount) + if (close !== -1) { + pushText() + nodes.push({ + type: 'inlineCode', + value: value + .slice(index + tickCount, close) + .replace(/\s+/g, ' ') + .trim(), + }) + index = close + tickCount + continue + } + } + + if (char === '!' && next === '[') { + const parsed = parseLinkish(value, index + 1) + if (parsed) { + pushText() + nodes.push({ + type: 'image', + src: sanitizeUrl(parsed.href), + alt: textFromMarkdown(parsed.label), + ...(parsed.title ? { title: parsed.title } : {}), + }) + index = parsed.end + continue + } + } + + if (char === '[') { + const parsed = parseLinkish(value, index) + if (parsed) { + const href = sanitizeUrl(parsed.href) + pushText() + if (href) { + nodes.push({ + type: 'link', + href, + ...(parsed.title ? { title: parsed.title } : {}), + children: parseInlineRaw(parsed.label, options), + }) + } else { + nodes.push(...parseInlineRaw(parsed.label, options)) + } + index = parsed.end + continue + } + } + + if ((char === '*' && next === '*') || (char === '_' && next === '_')) { + const close = findDelimiter(value, index + 2, char + char) + if (close !== -1) { + pushText() + nodes.push({ + type: 'strong', + children: parseInlineRaw(value.slice(index + 2, close), options), + }) + index = close + 2 + continue + } + text += char + next + index += 2 + continue + } + + if (char === '~' && next === '~') { + const close = findDelimiter(value, index + 2, '~~') + if (close !== -1) { + pushText() + nodes.push({ + type: 'strike', + children: parseInlineRaw(value.slice(index + 2, close), options), + }) + index = close + 2 + continue + } + text += '~~' + index += 2 + continue + } + + if (char === '*' || char === '_') { + const close = findDelimiter(value, index + 1, char) + if (close !== -1 && !isIntrawordUnderscore(value, index, close, char)) { + pushText() + nodes.push({ + type: 'emphasis', + children: parseInlineRaw(value.slice(index + 1, close), options), + }) + index = close + 1 + continue + } + } + + if (char === '<' && options.allowHtml) { + const element = parseInlineElement(value, index, options) + if (element) { + pushText() + nodes.push(element.node) + index = element.end + continue + } + + const close = value.indexOf('>', index + 1) + if (close !== -1) { + pushText() + nodes.push({ type: 'inlineHtml', value: value.slice(index, close + 1) }) + index = close + 1 + continue + } + } + + text += char + index++ + } + + pushText() + return nodes +} + +interface ParsedLink { + label: string + href: string + title?: string + end: number +} + +function parseLinkish(value: string, open: number): ParsedLink | undefined { + const closeBracket = findBalanced(value, open, '[', ']') + if (closeBracket === -1 || value[closeBracket + 1] !== '(') return undefined + + const closeParen = findBalanced(value, closeBracket + 1, '(', ')') + if (closeParen === -1) return undefined + + const label = value.slice(open + 1, closeBracket) + const destination = value.slice(closeBracket + 2, closeParen).trim() + const parsed = parseDestination(destination) + if (!parsed.href) return undefined + + return { + label, + href: parsed.href, + ...(parsed.title ? { title: parsed.title } : {}), + end: closeParen + 1, + } +} + +function parseDestination(value: string): { href: string; title?: string } { + const match = value.match(/^(\S+)(?:\s+["']([^"']*)["'])?$/) + if (!match) return { href: value } + return { + href: match[1]!.replace(/^<|>$/g, ''), + ...(match[2] ? { title: match[2] } : {}), + } +} + +function findBalanced( + value: string, + openIndex: number, + open: string, + close: string, +): number { + let depth = 0 + for (let index = openIndex; index < value.length; index++) { + if (value[index - 1] === '\\') continue + if (value[index] === open) depth++ + if (value[index] === close) { + depth-- + if (depth === 0) return index + } + } + return -1 +} + +function countRun(value: string, index: number, char: string): number { + let count = 0 + while (value[index + count] === char) count++ + return count +} + +function findClosingRun( + value: string, + start: number, + char: string, + count: number, +): number { + for (let index = start; index < value.length; index++) { + if (value[index] !== char) continue + if (countRun(value, index, char) >= count) return index + } + return -1 +} + +function findDelimiter( + value: string, + start: number, + delimiter: string, +): number { + for (let index = start; index < value.length; index++) { + if (value[index - 1] === '\\') continue + if (value.startsWith(delimiter, index)) return index + } + return -1 +} + +function isIntrawordUnderscore( + value: string, + open: number, + close: number, + delimiter: string, +): boolean { + if (delimiter !== '_') return false + return /\w/.test(value[open - 1] ?? '') && /\w/.test(value[close + 1] ?? '') +} + +function textFromMarkdown(value: string): string { + return parseInlineRaw(value, {}) + .map((node) => + node.type === 'text' || node.type === 'inlineCode' + ? node.value + : 'children' in node + ? textFromMarkdownFromNodes(node.children) + : node.type === 'image' + ? node.alt + : '', + ) + .join('') +} + +function textFromMarkdownFromNodes(nodes: InlineNode[]): string { + return nodes + .map((node) => + node.type === 'text' || node.type === 'inlineCode' + ? node.value + : 'children' in node + ? textFromMarkdownFromNodes(node.children) + : node.type === 'image' + ? node.alt + : '', + ) + .join('') +} + +interface ParsedInlineElement { + node: InlineNode + end: number +} + +const voidHtmlTags = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]) + +const inlineHtmlTags = new Set([ + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'del', + 'dfn', + 'em', + 'i', + 'ins', + 'kbd', + 'mark', + 'q', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', + 'wbr', +]) + +function parseInlineElement( + value: string, + index: number, + options: ParseOptions, +): ParsedInlineElement | undefined { + const opening = parseOpeningHtmlTag(value, index) + if (!opening || !inlineHtmlTags.has(opening.tagName)) return undefined + + if (opening.void || voidHtmlTags.has(opening.tagName)) { + return { + node: { + type: 'inlineElement', + tagName: opening.tagName, + attributes: opening.attributes, + children: [], + void: true, + }, + end: opening.end, + } + } + + const closing = findClosingHtmlTag(value, opening.end, opening.tagName) + if (!closing) return undefined + + return { + node: { + type: 'inlineElement', + tagName: opening.tagName, + attributes: opening.attributes, + children: parseInlineRaw( + value.slice(opening.end, closing.start), + options, + ), + void: false, + }, + end: closing.end, + } +} + +interface ParsedOpeningHtmlTag { + tagName: string + attributes: Record + end: number + void: boolean +} + +function parseOpeningHtmlTag( + value: string, + index: number, +): ParsedOpeningHtmlTag | undefined { + const match = value + .slice(index) + .match(/^<([A-Za-z][\w:-]*)([^<>]*?)\s*(\/?)>/) + + if (!match) return undefined + + const tagName = match[1]!.toLowerCase() + const rawAttributes = match[2] ?? '' + const raw = match[0]! + + return { + tagName, + attributes: parseHtmlAttributes(rawAttributes), + end: index + raw.length, + void: Boolean(match[3]), + } +} + +interface ClosingHtmlTag { + start: number + end: number +} + +function findClosingHtmlTag( + value: string, + start: number, + tagName: string, +): ClosingHtmlTag | undefined { + const pattern = new RegExp( + `<\\s*/?\\s*${escapeRegex(tagName)}(?:\\s[^>]*)?>`, + 'gi', + ) + pattern.lastIndex = start + + let depth = 1 + let match: RegExpExecArray | null + + while ((match = pattern.exec(value))) { + const raw = match[0]! + const isClosing = /^<\s*\//.test(raw) + const isSelfClosing = /\/\s*>$/.test(raw) + + if (isClosing) { + depth-- + if (depth === 0) { + return { + start: match.index, + end: pattern.lastIndex, + } + } + continue + } + + if (!isSelfClosing && !voidHtmlTags.has(tagName)) depth++ + } + + return undefined +} + +function parseHtmlAttributes(value: string): Record { + const attributes: Record = {} + const pattern = + /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g + + for (const match of value.matchAll(pattern)) { + const name = match[1]! + attributes[name] = match[2] ?? match[3] ?? match[4] ?? '' + } + + return attributes +} + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function mergeText(nodes: InlineNode[]): InlineNode[] { + const result: InlineNode[] = [] + for (const node of nodes) { + const previous = result.at(-1) + if (previous?.type === 'text' && node.type === 'text') { + previous.value += node.value + } else { + result.push(node) + } + } + return result +} diff --git a/packages/markdown/src/parser.ts b/packages/markdown/src/parser.ts new file mode 100644 index 000000000..7ae6d307e --- /dev/null +++ b/packages/markdown/src/parser.ts @@ -0,0 +1,476 @@ +import { parseInline } from './inline.js' +import type { + BlockNode, + CodeBlockNode, + HeadingNode, + InlineNode, + ListItemNode, + ListNode, + MarkdownDocument, + ParseOptions, + TableCellNode, + TableNode, +} from './types.js' +import { + createSlugger, + isBlank, + normalizeInput, + plainText, + stripIndent, +} from './utils.js' + +type Slugger = ReturnType + +export function parseMarkdown( + markdown: string, + options: ParseOptions = {}, +): MarkdownDocument { + const normalized = normalizeInput(markdown) + const frontmatterEnabled = options.frontmatter !== false + let lines = normalized.split('\n') + let frontmatter: string | undefined + + if (frontmatterEnabled && lines[0] === '---') { + const end = lines.findIndex((line, index) => index > 0 && line === '---') + if (end > 0) { + frontmatter = lines.slice(1, end).join('\n') + lines = lines.slice(end + 1) + } + } + + const parser = new BlockParser(lines, options, createSlugger()) + const children = parser.parse() + let document: MarkdownDocument = + frontmatter === undefined + ? { type: 'root', children } + : { type: 'root', frontmatter, children } + + for (const extension of options.extensions ?? []) { + document = extension.transformDocument?.(document, { options }) ?? document + } + + return document +} + +class BlockParser { + private index = 0 + + constructor( + private readonly lines: string[], + private readonly options: ParseOptions, + private readonly slugger: Slugger, + ) {} + + parse(): BlockNode[] { + const nodes: BlockNode[] = [] + + while (this.index < this.lines.length) { + if (isBlank(this.current())) { + this.index++ + continue + } + + const extensionNode = this.parseExtensionBlock() + if (extensionNode) { + nodes.push(extensionNode) + continue + } + + const node = + this.parseFence() ?? + this.parseHeading() ?? + this.parseThematicBreak() ?? + this.parseBlockquote() ?? + this.parseList() ?? + this.parseTable() ?? + this.parseHtmlBlock() ?? + this.parseParagraph() + + nodes.push(node) + } + + return nodes + } + + private parseExtensionBlock(): BlockNode | undefined { + for (const extension of this.options.extensions ?? []) { + let consumed = 0 + const node = extension.parseBlock?.({ + lines: this.lines, + index: this.index, + options: this.options, + parseInline: (value) => parseInline(value, this.options), + parseBlocks: (value) => + new BlockParser( + normalizeInput(value).split('\n'), + this.options, + this.slugger, + ).parse(), + consume: (lines) => { + consumed = lines + }, + }) + + if (node) { + this.index += Math.max(consumed, 1) + return node + } + } + + return undefined + } + + private parseFence(): CodeBlockNode | undefined { + const match = this.current().match(/^ {0,3}(`{3,}|~{3,})(.*)$/) + if (!match) return undefined + + const fence = match[1]! + const marker = fence[0]! + const fenceSize = fence.length + const info = match[2]!.trim() + const code: string[] = [] + this.index++ + + while (this.index < this.lines.length) { + const line = this.current() + const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/) + if (close && close[1]![0] === marker && close[1]!.length >= fenceSize) { + this.index++ + break + } + code.push(line) + this.index++ + } + + return { + type: 'code', + value: code.join('\n'), + ...parseCodeInfo(info), + } + } + + private parseHeading(): HeadingNode | undefined { + const match = this.current().match(/^ {0,3}(#{1,6})(?:\s+|$)(.*?)\s*#*\s*$/) + if (!match) return undefined + + const depth = getHeadingDepth(match[1]!) + const children = parseInline(match[2]!.trim(), this.options) + const id = this.createHeadingId(children) + this.index++ + + return id + ? { type: 'heading', depth, id, children } + : { type: 'heading', depth, children } + } + + private parseThematicBreak(): BlockNode | undefined { + if (!/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(this.current())) + return undefined + this.index++ + return { type: 'thematicBreak' } + } + + private parseBlockquote(): BlockNode | undefined { + if (!/^ {0,3}>\s?/.test(this.current())) return undefined + + const quoted: string[] = [] + while (this.index < this.lines.length) { + const line = this.current() + if (isBlank(line)) { + quoted.push('') + this.index++ + continue + } + + const match = line.match(/^ {0,3}>\s?(.*)$/) + if (!match) break + quoted.push(match[1]!) + this.index++ + } + + return { + type: 'blockquote', + children: new BlockParser(quoted, this.options, this.slugger).parse(), + } + } + + private parseList(): ListNode | undefined { + const first = listMarker(this.current()) + if (!first) return undefined + + const items: ListItemNode[] = [] + const ordered = first.ordered + const baseIndent = first.indent + const start = ordered ? first.number : undefined + + while (this.index < this.lines.length) { + const marker = listMarker(this.current()) + if (!marker || marker.ordered !== ordered || marker.indent !== baseIndent) + break + + let firstLine = marker.content + const task = firstLine.match(/^\[([ xX])\]\s+(.*)$/) + const checked = task ? task[1]!.toLowerCase() === 'x' : undefined + if (task) firstLine = task[2]! + + const itemLines = [firstLine] + this.index++ + + while (this.index < this.lines.length) { + const line = this.current() + const nextMarker = listMarker(line) + if ( + nextMarker && + nextMarker.indent === baseIndent && + nextMarker.ordered === ordered + ) + break + if (isBlank(line)) { + itemLines.push('') + this.index++ + continue + } + if (leadingSpaces(line) > baseIndent) { + itemLines.push(stripIndent(line, baseIndent + 2)) + this.index++ + continue + } + if (isBlockStart(line, this.next())) break + itemLines.push(line.trimStart()) + this.index++ + } + + const children = new BlockParser( + itemLines, + this.options, + this.slugger, + ).parse() + items.push( + checked === undefined + ? { type: 'listItem', children } + : { type: 'listItem', checked, children }, + ) + } + + return ordered && start !== undefined + ? { type: 'list', ordered, start, items } + : { type: 'list', ordered, items } + } + + private parseTable(): TableNode | undefined { + const header = this.current() + const delimiter = this.next() + if (!delimiter || !looksLikeTableHeader(header, delimiter)) return undefined + + const headerCells = splitTableRow(header) + const align = splitTableRow(delimiter).map(parseAlign) + const rows: TableCellNode[][] = [] + this.index += 2 + + while (this.index < this.lines.length) { + const line = this.current() + if (isBlank(line) || !line.includes('|')) break + rows.push(splitTableRow(line).map((value) => cell(value, this.options))) + this.index++ + } + + return { + type: 'table', + align, + header: headerCells.map((value) => cell(value, this.options)), + rows, + } + } + + private parseHtmlBlock(): BlockNode | undefined { + if ( + !this.options.allowHtml || + !/^ {0,3}<([A-Za-z][\w:-]*|!--|\/[A-Za-z])/.test(this.current()) + ) + return undefined + + const html: string[] = [] + while (this.index < this.lines.length && !isBlank(this.current())) { + html.push(this.current()) + this.index++ + } + + return { type: 'html', value: html.join('\n') } + } + + private parseParagraph(): BlockNode { + const lines: string[] = [] + + while (this.index < this.lines.length) { + const line = this.current() + if (isBlank(line)) break + if (lines.length > 0 && isBlockStart(line, this.next())) break + lines.push(line.trim()) + this.index++ + } + + return { + type: 'paragraph', + children: parseInline(lines.join('\n'), this.options), + } + } + + private createHeadingId(children: InlineNode[]): string | undefined { + if (this.options.headingIds === false) return undefined + const text = plainText(children) + if (typeof this.options.headingIds === 'function') + return this.options.headingIds(text, this.index) + return this.slugger(text) + } + + private current(): string { + return this.lines[this.index] ?? '' + } + + private next(): string | undefined { + return this.lines[this.index + 1] + } +} + +function parseCodeInfo(info: string): Omit { + if (!info) return {} + + const langMatch = info.match(/^([A-Za-z0-9_+.#-]+)/) + const lang = langMatch?.[1] + const meta = lang ? info.slice(lang.length).trim() : info + const titleMatch = meta.match( + /(?:^|\s)(?:title|file)=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/, + ) + const frameworkMatch = meta.match( + /(?:^|\s)framework=(?:"([^"]+)"|'([^']+)'|([^\s}]+))/, + ) + const rangeMatch = meta.match(/\{([^}]+)\}|(?:^|\s)lines=([^\s]+)/) + const highlightLines = parseLineRanges( + rangeMatch?.[1] ?? rangeMatch?.[2] ?? '', + ) + const title = titleMatch + ? (titleMatch[1] ?? titleMatch[2] ?? titleMatch[3]) + : undefined + const framework = frameworkMatch + ? (frameworkMatch[1] ?? frameworkMatch[2] ?? frameworkMatch[3]) + : undefined + + return { + ...(lang ? { lang } : {}), + ...(meta ? { meta } : {}), + ...(title ? { title, file: title } : {}), + ...(framework ? { framework: framework.toLowerCase() } : {}), + ...(highlightLines.length ? { highlightLines } : {}), + } +} + +function getHeadingDepth(value: string): HeadingNode['depth'] { + switch (value.length) { + case 1: + return 1 + case 2: + return 2 + case 3: + return 3 + case 4: + return 4 + case 5: + return 5 + default: + return 6 + } +} + +function parseLineRanges(value: string): number[] { + const lines = new Set() + for (const part of value.split(',')) { + const match = part.trim().match(/^(\d+)(?:-(\d+))?$/) + if (!match) continue + const start = Number(match[1]) + const end = Number(match[2] ?? match[1]) + for (let line = start; line <= end && line < start + 1000; line++) + lines.add(line) + } + return [...lines].sort((a, b) => a - b) +} + +function listMarker(line: string): + | { + ordered: boolean + number?: number + indent: number + content: string + } + | undefined { + const match = line.match(/^(\s{0,8})([-+*]|\d{1,9}[.)])\s+(.*)$/) + if (!match) return undefined + + const marker = match[2]! + const ordered = /\d/.test(marker[0]!) + return { + ordered, + ...(ordered ? { number: Number.parseInt(marker, 10) } : {}), + indent: match[1]!.length, + content: match[3]!, + } +} + +function leadingSpaces(line: string): number { + return line.match(/^ */)?.[0].length ?? 0 +} + +function isBlockStart(line: string, next?: string): boolean { + return ( + /^ {0,3}(`{3,}|~{3,})/.test(line) || + /^ {0,3}#{1,6}(?:\s+|$)/.test(line) || + /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line) || + /^ {0,3}>\s?/.test(line) || + listMarker(line) !== undefined || + (!!next && looksLikeTableHeader(line, next)) + ) +} + +function looksLikeTableHeader(header: string, delimiter: string): boolean { + if (!header.includes('|')) return false + const cells = splitTableRow(delimiter) + return ( + cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) + ) +} + +function splitTableRow(value: string): string[] { + let row = value.trim() + if (row.startsWith('|')) row = row.slice(1) + if (row.endsWith('|')) row = row.slice(0, -1) + + const cells: string[] = [] + let current = '' + for (let index = 0; index < row.length; index++) { + const char = row[index]! + if (char === '\\' && row[index + 1] === '|') { + current += '|' + index++ + continue + } + if (char === '|') { + cells.push(current.trim()) + current = '' + continue + } + current += char + } + cells.push(current.trim()) + return cells +} + +function parseAlign(value: string): 'left' | 'center' | 'right' | undefined { + const trimmed = value.trim() + if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center' + if (trimmed.endsWith(':')) return 'right' + if (trimmed.startsWith(':')) return 'left' + return undefined +} + +function cell(value: string, options: ParseOptions): TableCellNode { + return { type: 'tableCell', children: parseInline(value, options) } +} diff --git a/packages/markdown/src/react.ts b/packages/markdown/src/react.ts new file mode 100644 index 000000000..e0670036b --- /dev/null +++ b/packages/markdown/src/react.ts @@ -0,0 +1,425 @@ +import { Fragment, createElement } from 'react' +import type { ComponentType, ReactElement, ReactNode } from 'react' +import { parseMarkdown } from './parser.js' +import type { + BlockNode, + ComponentNode, + InlineNode, + MarkdownInput, + RenderOptions, + TableCellNode, +} from './types.js' + +type ComponentMap = Partial>> + +export interface MarkdownReactOptions extends RenderOptions { + components?: ComponentMap +} + +export interface MarkdownProps extends MarkdownReactOptions { + children: MarkdownInput +} + +export function Markdown({ + children, + ...options +}: MarkdownProps): ReactElement { + return createElement(Fragment, null, renderMarkdownReact(children, options)) +} + +export function renderMarkdownReact( + input: MarkdownInput, + options: MarkdownReactOptions = {}, +): ReactNode { + const document = + typeof input === 'string' ? parseMarkdown(input, options) : input + return document.children.map((node, index) => + renderBlockReact(node, options, `b:${index}`), + ) +} + +export function renderBlockReact( + node: BlockNode, + options: MarkdownReactOptions = {}, + key?: string, +): ReactElement { + switch (node.type) { + case 'heading': + return h( + options, + `h${node.depth}`, + { + key, + ...(node.id ? { id: node.id } : {}), + ...(node.framework ? { 'data-framework': node.framework } : {}), + }, + renderInlines(node.children, options), + renderHeadingAnchorReact(node.id, options), + ) + case 'paragraph': + return h(options, 'p', { key }, renderInlines(node.children, options)) + case 'code': + return renderCodeBlockReact(node, options, key) + case 'list': { + const tag = node.ordered ? 'ol' : 'ul' + return h( + options, + tag, + { + key, + ...(node.ordered && node.start && node.start !== 1 + ? { start: node.start } + : {}), + }, + node.items.map((item, index) => + h( + options, + 'li', + { key: index }, + item.checked === undefined + ? null + : h(options, 'input', { + type: 'checkbox', + disabled: true, + checked: item.checked, + readOnly: true, + }), + item.checked === undefined ? null : ' ', + item.children.map((child, childIndex) => + renderBlockReact(child, options, `${index}:${childIndex}`), + ), + ), + ), + ) + } + case 'blockquote': + return h( + options, + 'blockquote', + { key }, + node.children.map((child, index) => + renderBlockReact(child, options, `${key}:${index}`), + ), + ) + case 'table': + return h( + options, + 'table', + { key }, + h( + options, + 'thead', + null, + h( + options, + 'tr', + null, + node.header.map((cell, index) => + renderTableCellReact( + 'th', + cell, + node.align[index], + options, + index, + ), + ), + ), + ), + h( + options, + 'tbody', + null, + node.rows.map((row, rowIndex) => + h( + options, + 'tr', + { key: rowIndex }, + row.map((cell, index) => + renderTableCellReact( + 'td', + cell, + node.align[index], + options, + index, + ), + ), + ), + ), + ), + ) + case 'thematicBreak': + return h(options, 'hr', { key }) + case 'html': + return options.allowHtml + ? h(options, 'div', { + key, + dangerouslySetInnerHTML: { __html: node.value }, + }) + : h(options, 'p', { key }, node.value) + case 'callout': + return h( + options, + 'div', + { + key, + className: `markdown-alert markdown-alert-${node.kind.toLowerCase()}`, + }, + h(options, 'p', { className: 'markdown-alert-title' }, node.title), + h( + options, + 'div', + { className: 'markdown-alert-content' }, + node.children.map((child, index) => + renderBlockReact(child, options, `${key}:${index}`), + ), + ), + ) + case 'component': + return renderComponentReact(node, options, key) + } +} + +export function renderInlineReact( + node: InlineNode, + options: MarkdownReactOptions = {}, + key?: string, +): ReactNode { + switch (node.type) { + case 'text': + return node.value + case 'inlineCode': + return h(options, 'code', { key }, node.value) + case 'strong': + return h( + options, + 'strong', + { key }, + renderInlines(node.children, options), + ) + case 'emphasis': + return h(options, 'em', { key }, renderInlines(node.children, options)) + case 'strike': + return h(options, 's', { key }, renderInlines(node.children, options)) + case 'link': + return h( + options, + 'a', + { key, href: node.href, ...(node.title ? { title: node.title } : {}) }, + renderInlines(node.children, options), + ) + case 'image': + return h(options, 'img', { + key, + src: node.src, + alt: node.alt, + ...(node.title ? { title: node.title } : {}), + }) + case 'break': + return h(options, 'br', { key }) + case 'inlineElement': + return renderInlineElementReact(node, options, key) + case 'inlineHtml': + return options.allowHtml + ? h(options, 'span', { + key, + dangerouslySetInnerHTML: { __html: node.value }, + }) + : node.value + } +} + +function renderInlines( + nodes: InlineNode[], + options: MarkdownReactOptions, +): ReactNode[] { + return nodes.map((node, index) => + renderInlineReact(node, options, `i:${index}`), + ) +} + +function renderCodeBlockReact( + node: Extract, + options: MarkdownReactOptions, + key?: string, +): ReactElement { + const lang = node.lang ?? 'plaintext' + const highlighter = options.highlighter + + const codeProps = { + className: `language-${lang}`, + } + + const content = highlighter ? undefined : node.value + + const highlighted = highlighter + ? { + dangerouslySetInnerHTML: { + __html: highlighter(node.value, lang, { + ...(node.highlightLines + ? { highlightLines: node.highlightLines } + : {}), + ...(options.codeLineNumbers !== undefined + ? { lineNumbers: options.codeLineNumbers } + : {}), + }), + }, + } + : undefined + + const pre = h( + options, + 'pre', + { + className: 'tm-code', + 'data-lang': lang, + ...(node.title ? { 'data-code-title': node.title } : {}), + ...(node.file ? { 'data-filename': node.file } : {}), + ...(node.framework ? { 'data-framework': node.framework } : {}), + }, + h(options, 'code', { ...codeProps, ...highlighted }, content), + ) + + if (!node.title) return h(options, Fragment, { key }, pre) + + return h( + options, + 'figure', + { key, className: 'tm-code-frame', 'data-lang': lang }, + h(options, 'figcaption', null, node.title), + pre, + ) +} + +function renderTableCellReact( + tag: 'td' | 'th', + cell: TableCellNode, + align: 'left' | 'center' | 'right' | undefined, + options: MarkdownReactOptions, + key: number, +): ReactElement { + return h( + options, + tag, + { key, ...(align ? { style: { textAlign: align } } : {}) }, + renderInlines(cell.children, options), + ) +} + +function renderInlineElementReact( + node: Extract, + options: MarkdownReactOptions, + key?: string, +): ReactElement { + const props = { + key, + ...createReactAttributes(node.attributes), + } + + if (node.void) return h(options, node.tagName, props) + return h(options, node.tagName, props, renderInlines(node.children, options)) +} + +function createReactAttributes( + attributes: Record, +): Record { + const props: Record = {} + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'class') { + props.className = value + continue + } + + if (key === 'style') { + props.style = parseStyleAttribute(value) + continue + } + + props[key] = value || true + } + + return props +} + +function parseStyleAttribute(value: string): Record { + const style: Record = {} + + for (const declaration of value.split(';')) { + const colon = declaration.indexOf(':') + if (colon === -1) continue + + const property = declaration.slice(0, colon).trim() + const propertyValue = declaration.slice(colon + 1).trim() + if (!property || !propertyValue) continue + + style[camelizeCssProperty(property)] = propertyValue + } + + return style +} + +function camelizeCssProperty(property: string) { + if (property.startsWith('--')) return property + return property.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ) +} + +function h( + options: MarkdownReactOptions, + tag: string | typeof Fragment, + props: Record | null, + ...children: ReactNode[] +): ReactElement { + const component = + typeof tag === 'string' ? (options.components?.[tag] ?? tag) : tag + return createElement(component, props, ...children) +} + +function renderComponentReact( + node: ComponentNode, + options: MarkdownReactOptions, + key?: string, +): ReactElement { + const tag = node.tagName ?? 'md-comment-component' + const props: Record = { + ...(node.properties ?? {}), + } + + if (!node.tagName) { + props['data-component'] = node.name + if (!props['data-attributes']) + props['data-attributes'] = JSON.stringify(node.attributes) + } + + return h( + options, + tag, + { key, ...props }, + node.children.map((child, index) => + renderBlockReact(child, options, `${key}:${index}`), + ), + ) +} + +function renderHeadingAnchorReact( + id: string | undefined, + options: MarkdownReactOptions, +): ReactNode { + if (!id || !options.headingAnchors) return null + + const anchorOptions = + typeof options.headingAnchors === 'object' ? options.headingAnchors : {} + return h( + options, + 'a', + { + href: `#${id}`, + 'aria-hidden': anchorOptions.ariaHidden ?? true, + className: + anchorOptions.className ?? 'anchor-heading anchor-heading-link', + tabIndex: anchorOptions.tabIndex ?? -1, + }, + anchorOptions.content ?? '#', + ) +} diff --git a/packages/markdown/src/types.ts b/packages/markdown/src/types.ts new file mode 100644 index 000000000..cd67cf76d --- /dev/null +++ b/packages/markdown/src/types.ts @@ -0,0 +1,243 @@ +export type MarkdownInput = string | MarkdownDocument + +export interface MarkdownDocument { + type: 'root' + children: BlockNode[] + frontmatter?: string + headings?: MarkdownHeading[] +} + +export type BlockNode = + | HeadingNode + | ParagraphNode + | CodeBlockNode + | ListNode + | BlockquoteNode + | TableNode + | ThematicBreakNode + | HtmlBlockNode + | CalloutNode + | ComponentNode + +export interface HeadingNode { + type: 'heading' + depth: 1 | 2 | 3 | 4 | 5 | 6 + id?: string + framework?: string + children: InlineNode[] +} + +export interface ParagraphNode { + type: 'paragraph' + children: InlineNode[] +} + +export interface CodeBlockNode { + type: 'code' + lang?: string + meta?: string + title?: string + framework?: string + file?: string + value: string + highlightLines?: number[] +} + +export interface ListNode { + type: 'list' + ordered: boolean + start?: number + items: ListItemNode[] +} + +export interface ListItemNode { + type: 'listItem' + checked?: boolean + children: BlockNode[] +} + +export interface BlockquoteNode { + type: 'blockquote' + children: BlockNode[] +} + +export interface TableNode { + type: 'table' + align: Array<'left' | 'center' | 'right' | undefined> + header: TableCellNode[] + rows: TableCellNode[][] +} + +export interface TableCellNode { + type: 'tableCell' + children: InlineNode[] +} + +export interface ThematicBreakNode { + type: 'thematicBreak' +} + +export interface HtmlBlockNode { + type: 'html' + value: string +} + +export interface CalloutNode { + type: 'callout' + kind: string + title: string + children: BlockNode[] +} + +export interface ComponentNode { + type: 'component' + name: string + attributes: Record + children: BlockNode[] + tagName?: string + properties?: Record +} + +export type InlineNode = + | TextNode + | CodeSpanNode + | StrongNode + | EmphasisNode + | StrikeNode + | LinkNode + | ImageNode + | BreakNode + | HtmlInlineElementNode + | HtmlInlineNode + +export interface TextNode { + type: 'text' + value: string +} + +export interface CodeSpanNode { + type: 'inlineCode' + value: string +} + +export interface StrongNode { + type: 'strong' + children: InlineNode[] +} + +export interface EmphasisNode { + type: 'emphasis' + children: InlineNode[] +} + +export interface StrikeNode { + type: 'strike' + children: InlineNode[] +} + +export interface LinkNode { + type: 'link' + href: string + title?: string + children: InlineNode[] +} + +export interface ImageNode { + type: 'image' + src: string + alt: string + title?: string +} + +export interface BreakNode { + type: 'break' +} + +export interface HtmlInlineNode { + type: 'inlineHtml' + value: string +} + +export interface HtmlInlineElementNode { + type: 'inlineElement' + tagName: string + attributes: Record + children: InlineNode[] + void: boolean +} + +export interface MarkdownExtension { + name: string + parseBlock?: (context: BlockParseContext) => BlockNode | undefined + transformDocument?: ( + document: MarkdownDocument, + context: DocumentTransformContext, + ) => MarkdownDocument | void + transformInline?: ( + nodes: InlineNode[], + context: InlineTransformContext, + ) => InlineNode[] + renderHtml?: ( + node: BlockNode | InlineNode, + context: HtmlRenderContext, + ) => string | undefined +} + +export interface BlockParseContext { + lines: string[] + index: number + options: ParseOptions + parseInline: (value: string) => InlineNode[] + parseBlocks: (value: string) => BlockNode[] + consume: (lines: number) => void +} + +export interface InlineTransformContext { + options: ParseOptions +} + +export interface DocumentTransformContext { + options: ParseOptions +} + +export interface HtmlRenderContext { + options: RenderOptions + renderBlock: (node: BlockNode) => string + renderInline: (node: InlineNode) => string +} + +export interface ParseOptions { + allowHtml?: boolean + frontmatter?: boolean + headingIds?: boolean | ((text: string, index: number) => string) + extensions?: MarkdownExtension[] +} + +export interface RenderOptions extends ParseOptions { + highlighter?: CodeHighlighter + codeLineNumbers?: boolean + headingAnchors?: boolean | HeadingAnchorOptions +} + +export interface CodeHighlighter { + (code: string, lang?: string, options?: CodeHighlightOptions): string +} + +export interface CodeHighlightOptions { + highlightLines?: number[] + lineNumbers?: boolean +} + +export interface HeadingAnchorOptions { + content?: string + className?: string + ariaHidden?: boolean + tabIndex?: number +} + +export interface MarkdownHeading { + id: string + text: string + level: number + framework?: string +} diff --git a/packages/markdown/src/utils.ts b/packages/markdown/src/utils.ts new file mode 100644 index 000000000..f3ffeffb6 --- /dev/null +++ b/packages/markdown/src/utils.ts @@ -0,0 +1,80 @@ +import type { InlineNode } from './types.js' + +const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} + +export function normalizeInput(value: string): string { + return value.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n') +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => htmlEscapes[char]!) +} + +export function escapeAttr(value: string): string { + return escapeHtml(value).replace(/`/g, '`') +} + +export function isBlank(value: string): boolean { + return /^\s*$/.test(value) +} + +export function stripIndent(value: string, size: number): string { + let index = 0 + while (index < value.length && index < size && value[index] === ' ') index++ + return value.slice(index) +} + +export function plainText(nodes: InlineNode[]): string { + let value = '' + for (const node of nodes) { + if (node.type === 'text' || node.type === 'inlineCode') value += node.value + else if ('children' in node) value += plainText(node.children) + else if (node.type === 'image') value += node.alt + } + return value +} + +export function createSlugger() { + const seen = new Map() + + return (value: string) => { + const base = + value + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&[a-z0-9#]+;/gi, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'section' + + const count = seen.get(base) ?? 0 + seen.set(base, count + 1) + return count === 0 ? base : `${base}-${count + 1}` + } +} + +export function sanitizeUrl(value: string): string { + const trimmed = Array.from(value.trim()) + .filter((char) => { + const code = char.codePointAt(0) ?? 0 + return code > 0x1f && code !== 0x7f && !/\s/.test(char) + }) + .join('') + if (!trimmed) return '' + if (/^(#|\/|\.\/|\.\.\/)/.test(trimmed)) return trimmed + if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return '' + return trimmed +} + +export function splitLines(value: string): string[] { + const lines = value.split('\n') + if (lines.at(-1) === '') lines.pop() + return lines +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f3c87947..4f6855721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 5.1.5 '@netlify/vite-plugin-tanstack-start': specifier: ^1.3.2 - version: 1.3.4(@tanstack/react-start@1.168.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.4(@tanstack/react-start@1.168.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@observablehq/plot': specifier: ^0.6.17 version: 0.6.17 @@ -104,6 +104,12 @@ importers: '@tanstack/create': specifier: ^0.68.0 version: 0.68.0(tslib@2.8.1) + '@tanstack/highlight': + specifier: workspace:* + version: link:packages/highlight + '@tanstack/markdown': + specifier: workspace:* + version: link:packages/markdown '@tanstack/pacer': specifier: ^0.21.1 version: 0.21.1 @@ -127,7 +133,7 @@ importers: version: 1.167.0(@tanstack/query-core@5.100.11)(@tanstack/react-query@5.100.11(react@19.2.3))(@tanstack/react-router@1.170.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: 1.168.10 - version: 1.168.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.168.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-start-client': specifier: 1.168.2 version: 1.168.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -146,9 +152,6 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitejs/plugin-rsc': - specifier: ^0.5.24 - version: 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@webcontainer/api': specifier: ^1.6.1 version: 1.6.1 @@ -182,12 +185,6 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 - hast-util-is-element: - specifier: ^3.0.0 - version: 3.0.0 - hast-util-to-string: - specifier: ^3.0.1 - version: 3.0.1 iconv-lite: specifier: ^0.7.2 version: 0.7.2 @@ -233,42 +230,12 @@ importers: react-instantsearch: specifier: ^7.29.0 version: 7.29.0(algoliasearch@5.50.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - rehype-autolink-headings: - specifier: ^7.1.0 - version: 7.1.0 - rehype-callouts: - specifier: ^2.1.2 - version: 2.1.2 - rehype-parse: - specifier: ^9.0.1 - version: 9.0.1 - rehype-raw: - specifier: ^7.0.0 - version: 7.0.0 - rehype-react: - specifier: ^8.0.0 - version: 8.0.0 - rehype-slug: - specifier: ^6.0.0 - version: 6.0.0 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 - remark-rehype: - specifier: ^11.1.2 - version: 11.1.2 remove-markdown: specifier: ^0.6.3 version: 0.6.3 resend: specifier: ^6.10.0 version: 6.10.0 - shiki: - specifier: ^4.0.2 - version: 4.0.2 streamdown: specifier: ^2.5.0 version: 2.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -284,12 +251,6 @@ importers: troika-three-text: specifier: ^0.52.4 version: 0.52.4(three@0.183.2) - unified: - specifier: ^11.0.5 - version: 11.0.5 - unist-util-visit: - specifier: ^5.1.0 - version: 5.1.0 uploadthing: specifier: ^7.7.4 version: 7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2) @@ -315,9 +276,6 @@ importers: '@content-collections/vite': specifier: ^0.2.9 version: 0.2.9(@content-collections/core@0.14.3(typescript@6.0.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) - '@shikijs/transformers': - specifier: ^4.0.2 - version: 4.0.2 '@tanstack/devtools-vite': specifier: ^0.7.0 version: 0.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -394,6 +352,14 @@ importers: specifier: ^8.0.13 version: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + packages/highlight: {} + + packages/markdown: + dependencies: + react: + specifier: '>=18' + version: 19.2.3 + packages: '@ag-ui/core@0.0.52': @@ -3125,9 +3091,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} - '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -3415,41 +3378,6 @@ packages: resolution: {integrity: sha512-i6NWUDi2SDikfSUeMJvJTRdwEKYSfTd+mvBO2Ja51S1YK+hnickBuDfD+RvPerIXLuyRu3GamgNPbNqgCGUg/Q==} engines: {node: '>= 18'} - '@shikijs/core@4.0.2': - resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} - engines: {node: '>=20'} - - '@shikijs/engine-javascript@4.0.2': - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} - engines: {node: '>=20'} - - '@shikijs/engine-oniguruma@4.0.2': - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} - engines: {node: '>=20'} - - '@shikijs/langs@4.0.2': - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} - engines: {node: '>=20'} - - '@shikijs/primitive@4.0.2': - resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} - engines: {node: '>=20'} - - '@shikijs/themes@4.0.2': - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} - engines: {node: '>=20'} - - '@shikijs/transformers@4.0.2': - resolution: {integrity: sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==} - engines: {node: '>=20'} - - '@shikijs/types@4.0.2': - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} - engines: {node: '>=20'} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@shopify/hydrogen-react@2026.4.0': resolution: {integrity: sha512-RXtMkkZAJFv43BcNR2CNIkVbUqT28nU9uzJ05lewkX9yq2C7Vmf5AC2jLov+tevMYFqTPOQRiZYuxtym4UnKSA==} peerDependencies: @@ -4302,17 +4230,6 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.24': - resolution: {integrity: sha512-FQ7o1Zf1GUB8L5qlIuV2mvIv/KahG2qUYW2gMpxyIN3zF7voDsfvA/t8w/TLjYC0T6p3JwMnK3N+YzMGf/m75A==} - peerDependencies: - react: '*' - react-dom: '*' - react-server-dom-webpack: '*' - vite: '*' - peerDependenciesMeta: - react-server-dom-webpack: - optional: true - '@vue/compiler-core@3.5.31': resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} @@ -5433,9 +5350,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -5763,9 +5677,6 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5864,18 +5775,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hast-util-from-html@2.0.3: - resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} - hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - hast-util-heading-rank@3.0.0: - resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -5885,18 +5787,12 @@ packages: hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} - hast-util-to-string@3.0.1: - resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} - hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -6300,9 +6196,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -6997,12 +6890,6 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} - oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - - oniguruma-to-es@4.3.5: - resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} - open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -7467,15 +7354,6 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -7484,31 +7362,15 @@ packages: resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} engines: {node: '>=8'} - rehype-autolink-headings@7.1.0: - resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} - - rehype-callouts@2.1.2: - resolution: {integrity: sha512-ZZWZ6EknUHiSzr4pQ88C7db3su4DElfJRmphZJbXpDdwW3urTwlYZpHckoC9pjEvBmUEEiJAM0uuc2uxyLdTfg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - rehype-harden@1.1.8: resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} - rehype-parse@9.0.1: - resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} - rehype-react@8.0.0: - resolution: {integrity: sha512-vzo0YxYbB2HE+36+9HWXVdxNoNDubx63r5LBzpxBGVWM8s9mdnMdbmuJBAX6TTyuGdZjZix6qU3GcSuKCIWivw==} - rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} - rehype-slug@6.0.0: - resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -7738,10 +7600,6 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shiki@4.0.2: - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} - engines: {node: '>=20'} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -7903,9 +7761,6 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -8102,9 +7957,6 @@ packages: tunnel-rat@0.1.2: resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} - turbo-stream@3.2.0: - resolution: {integrity: sha512-EK+bZ9UVrVh7JLslVFOV0GEMsociOqVOvEMTAd4ixMyffN5YNIEdLZWXUx5PJqDbTxSIBWw04HS9gCY4frYQDQ==} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -9980,12 +9832,12 @@ snapshots: '@netlify/types@2.6.0': {} - '@netlify/vite-plugin-tanstack-start@1.3.4(@tanstack/react-start@1.168.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': + '@netlify/vite-plugin-tanstack-start@1.3.4(@tanstack/react-start@1.168.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@netlify/vite-plugin': 2.11.4(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) vite: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: - '@tanstack/react-start': 1.168.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/react-start': 1.168.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -11177,8 +11029,6 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.1': optional: true - '@rolldown/pluginutils@1.0.0-rc.15': {} - '@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.1': {} @@ -11450,51 +11300,6 @@ snapshots: - rollup - supports-color - '@shikijs/core@4.0.2': - dependencies: - '@shikijs/primitive': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.5 - - '@shikijs/engine-oniguruma@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/primitive@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/themes@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 - - '@shikijs/transformers@4.0.2': - dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/types': 4.0.2 - - '@shikijs/types@4.0.2': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - '@shopify/hydrogen-react@2026.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.183.2)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@google/model-viewer': 4.2.0(three@0.183.2) @@ -11904,7 +11709,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-start-rsc@0.1.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start-rsc@0.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tanstack/react-router': 1.170.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-server': 1.167.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -11918,8 +11723,6 @@ snapshots: pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@vitejs/plugin-rsc': 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -11940,11 +11743,11 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start@1.168.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tanstack/react-router': 1.170.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-client': 1.168.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-rsc': 0.1.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/react-start-rsc': 0.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-start-server': 1.167.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-utils': 1.162.1 '@tanstack/start-client-core': 1.170.2 @@ -11954,7 +11757,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@vitejs/plugin-rsc': 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) vite: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@rspack/core' @@ -12469,20 +12271,6 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.15 - es-module-lexer: 2.0.0 - estree-walker: 3.0.3 - magic-string: 0.30.21 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - srvx: 0.11.15 - strip-literal: 3.1.0 - turbo-stream: 3.2.0 - vite: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vue/compiler-core@3.5.31': dependencies: '@babel/parser': 7.29.2 @@ -13633,8 +13421,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.0.0: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -14084,8 +13870,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-slugger@2.0.0: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -14175,15 +13959,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-from-html@2.0.3: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - hast-util-from-parse5: 8.0.3 - parse5: 7.3.0 - vfile: 6.0.3 - vfile-message: 4.0.3 - hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -14195,14 +13970,6 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 - hast-util-heading-rank@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -14229,20 +13996,6 @@ snapshots: '@ungap/structured-clone': 1.3.0 unist-util-position: 5.0.0 - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -14273,10 +14026,6 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-string@3.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -14694,8 +14443,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -15606,14 +15353,6 @@ snapshots: dependencies: mimic-fn: 4.0.0 - oniguruma-parser@0.12.1: {} - - oniguruma-to-es@4.3.5: - dependencies: - oniguruma-parser: 0.12.1 - regex: 6.1.0 - regex-recursion: 6.0.2 - open@7.4.2: dependencies: is-docker: 2.2.1 @@ -16139,16 +15878,6 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -16160,60 +15889,21 @@ snapshots: regexparam@2.0.2: {} - rehype-autolink-headings@7.1.0: - dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 - hast-util-heading-rank: 3.0.0 - hast-util-is-element: 3.0.0 - unified: 11.0.5 - unist-util-visit: 5.1.0 - - rehype-callouts@2.1.2: - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - hast-util-is-element: 3.0.0 - hastscript: 9.0.1 - unist-util-visit: 5.1.0 - rehype-harden@1.1.8: dependencies: unist-util-visit: 5.1.0 - rehype-parse@9.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - unified: 11.0.5 - rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-react@8.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-jsx-runtime: 2.3.6 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - rehype-sanitize@6.0.0: dependencies: '@types/hast': 3.0.4 hast-util-sanitize: 5.0.2 - rehype-slug@6.0.0: - dependencies: - '@types/hast': 3.0.4 - github-slugger: 2.0.0 - hast-util-heading-rank: 3.0.0 - hast-util-to-string: 3.0.1 - unist-util-visit: 5.1.0 - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -16539,17 +16229,6 @@ snapshots: shell-quote@1.8.3: {} - shiki@4.0.2: - dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/engine-javascript': 4.0.2 - '@shikijs/engine-oniguruma': 4.0.2 - '@shikijs/langs': 4.0.2 - '@shikijs/themes': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -16754,10 +16433,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -16965,8 +16640,6 @@ snapshots: - react - use-sync-external-store - turbo-stream@3.2.0: {} - type-fest@4.41.0: {} type-is@2.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e45eaf49..a5d48b504 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +packages: + - . + - packages/* + overrides: cross-spawn: '>=6.0.6' glob: '>=10.5.0' diff --git a/rsc-migration-report.md b/rsc-migration-report.md deleted file mode 100644 index 8dd677517..000000000 --- a/rsc-migration-report.md +++ /dev/null @@ -1,197 +0,0 @@ -# RSC Migration Report - -Status: bundle + code audit complete. Production Lighthouse baseline and post-deploy compare captured. - -## Scope - -This report tracks the impact of moving markdown-heavy and code-heavy surfaces to React Server Components and server-rendered code highlighting. - -Pages explicitly covered: - -- `/blog/react-server-components` -- `/router/latest/docs/overview` -- `/router/latest/docs/framework/react/examples/basic` -- `/query/latest` -- `/router/latest` -- `/form/latest` -- `/table/latest` -- `/virtual/latest` - -## Methodology - -Bundle analysis: - -- Baseline: clean `HEAD` worktree at `/tmp/tanstack-bundle-baseline` -- Current: this branch/worktree -- Metric: transitive built client JS graph per representative route from production build assets -- Unit: bytes and gzip bytes - -Production baseline metrics: - -- Captured against live `https://tanstack.com` -- Tool: Lighthouse via `npx lighthouse` -- Category: `performance` -- Strategy: default Lighthouse mobile simulation - -Notes: - -- Bundle numbers are the most trustworthy pre-ship metric here -- Lighthouse should be compared on the same production domain before and after deploy -- Production Lighthouse is noisy; treat single-run numbers as directional until we rerun after deploy - -## Bundle Impact - -Gzipped client JS, before vs after. - -| Page | Before | After | Delta | -| ---------------------------------------------------- | --------: | --------: | ---------: | -| `/blog/react-server-components` | 547,196 B | 394,207 B | -152,989 B | -| `/router/latest/docs/overview` | 563,295 B | 410,644 B | -152,651 B | -| `/router/latest/docs/framework/react/examples/basic` | 421,421 B | 381,079 B | -40,342 B | -| `/table/latest` | 451,744 B | 409,455 B | -42,289 B | -| `/query/latest` | 405,185 B | 412,671 B | +7,486 B | -| `/router/latest` | 403,281 B | 411,259 B | +7,978 B | -| `/form/latest` | 401,951 B | 409,389 B | +7,438 B | -| `/virtual/latest` | 401,535 B | 409,044 B | +7,509 B | - -### Bundle Takeaways - -- Big wins on markdown-heavy pages: - - blog: about `-153 KB gz` - - docs page: about `-153 KB gz` -- Clear win on docs example page: about `-40 KB gz` -- Clear win on table landing: about `-42 KB gz` -- Slight regressions on most other landing pages: about `+7-8 KB gz` - -Interpretation: - -- The pages dominated by markdown parsing and client-side highlighting improved materially -- Most landing pages are now architecturally cleaner, but not smaller yet -- The likely reason for the small landing regressions is that the old landing code-example path had more lazy/client indirection, while the new server-rendered example shell is part of the initial experience - -## Client Boundary Audit - -Verified absent from current `dist/client/assets`: - -- `shiki` -- `@shikijs/*` -- `createHighlighter` -- `codeToHtml` -- `html-react-parser` -- `remark-*` -- `rehype-*` -- `rehype-react` -- old client markdown renderer files -- old landing example card files -- representative raw example-source snippets - -Implications: - -- Markdown rendering pipeline is out of the client build -- Syntax highlighting pipeline is out of the client build -- Landing example source strings are out of the client build - -Runtime spot checks also confirmed: - -- docs/example pages no longer request Shiki/highlighting assets -- landing pages no longer hit the old client `CodeBlock` path - -Nuance: - -- Client sourcemaps still reference some server-fn stub names like `fetchRenderedCodeFile` and `fetchLandingCodeExample` -- Those are stubs, not the rendering/highlighting pipeline itself - -## Code Simplicity - -Git diff summary: - -- `46 files changed` -- `1,017 insertions` -- `2,038 deletions` -- Net: about `-1,021` lines - -Deleted legacy client-heavy files: - -- `src/components/CodeExampleCard.tsx` -- `src/components/LazyCodeExampleCard.tsx` -- `src/components/SimpleMarkdown.tsx` -- `src/components/markdown/Markdown.tsx` -- `src/components/markdown/MarkdownFrameworkHandler.tsx` -- `src/components/markdown/MarkdownTabsHandler.tsx` -- `src/utils/markdown/processor.ts` - -New server-focused structure: - -- `src/components/markdown/renderCodeBlock.server.tsx` -- `src/components/markdown/CodeBlock.server.tsx` -- `src/utils/markdown/processor.rsc.tsx` -- `src/utils/markdown/renderRsc.tsx` -- `src/components/landing/codeExamples.server.tsx` -- `src/components/landing/LandingCodeExampleCard.server.tsx` - -Dependency cleanup: - -- Removed `html-react-parser` -- Removed `rehype-stringify` - -Architectural simplifications: - -- One server markdown pipeline instead of mixed client/server rendering -- One server code-highlighting pipeline instead of client Shiki -- One server landing-example registry instead of repeating large inline code maps across landing pages -- Example pages now use URL-driven server-rendered code panes instead of client raw source + client highlighting - -## Current Production Lighthouse Baseline - -Captured before shipping these changes. - -| Page | Score | FCP | LCP | Speed Index | TBT | CLS | Interactive | Bytes | Requests | -| ------------------------------- | ----: | ---: | ---: | ----------: | ------: | ----: | ----------: | --------: | -------: | -| `/query/latest` | 80 | 2.8s | 3.4s | 3.4s | 300ms | 0.001 | 6.1s | 765 KiB | 89 | -| `/blog/react-server-components` | 52 | 3.3s | 3.7s | 3.6s | 1,200ms | 0.15 | 7.8s | 1,101 KiB | 60 | -| `/router/latest/docs/overview` | 78 | 3.0s | 3.6s | 3.9s | 280ms | 0.002 | 7.5s | 917 KiB | 81 | - -Notes: - -- These are single-run live production baselines, so expect some noise -- Blog is the most likely page to show strong post-deploy Lighthouse improvement because it had the largest client-bundle reduction -- Docs should also improve meaningfully -- Landing-page Lighthouse changes may be neutral or mixed except for table, based on current bundle data - -## Post-Deploy Compare - -| Page | Before Score | After Score | Before LCP | After LCP | Before TBT | After TBT | Before Bytes | After Bytes | Notes | -| ------------------------------- | -----------: | ----------: | ---------: | --------: | ---------: | --------: | -----------: | ----------: | ------------------------------------------------------------------------ | -| `/query/latest` | 80 | 77 | 3.4s | 3.8s | 300ms | 250ms | 765 KiB | 784 KiB | Roughly flat. Slightly worse score/LCP on this run, slightly better TBT. | -| `/blog/react-server-components` | 52 | 74 | 3.7s | 3.6s | 1,200ms | 260ms | 1,101 KiB | 785 KiB | Clear win. Huge TBT and transfer-size drop, major score improvement. | -| `/router/latest/docs/overview` | 78 | 81 | 3.6s | 3.6s | 280ms | 200ms | 917 KiB | 777 KiB | Modest win. Better score, lower TBT, lower transfer size. | - -## Conclusions So Far - -- Strong technical wins on markdown-heavy and code-heavy content pages -- Clear reduction in client responsibility and code complexity -- Clear removal of markdown parsing and syntax highlighting from the client bundle -- Not a universal bundle win across all landing pages yet -- Best pages to watch post-deploy: blog and docs - -## Post-Deploy Read - -- Blog got the clearest real-world benefit. - - Lighthouse score improved from `52` to `74` - - TBT dropped from `1,200ms` to `260ms` - - transfer size dropped from `1,101 KiB` to `785 KiB` -- Docs improved, but less dramatically. - - Lighthouse score improved from `78` to `81` - - TBT dropped from `280ms` to `200ms` - - transfer size dropped from `917 KiB` to `777 KiB` -- Query landing did not show a Lighthouse win on this run. - - Score went from `80` to `77` - - LCP worsened slightly from `3.4s` to `3.8s` - - TBT improved slightly from `300ms` to `250ms` - -Interpretation: - -- The bundle-side story predicted this fairly well. -- The strongest wins landed on markdown-heavy pages where we removed the client markdown/highlighting pipeline. -- The landing-page story remains mixed because those pages were not dominated by markdown and some of the server-rendered example-shell work traded complexity reduction for slightly more initial shell work. -- If we want broader Lighthouse wins on library landings, the next work is probably not more RSC conversion. It is targeted landing-page performance work. diff --git a/src/auth/cli-tickets.server.ts b/src/auth/cli-tickets.server.ts index 35f1431fe..7be3e0839 100644 --- a/src/auth/cli-tickets.server.ts +++ b/src/auth/cli-tickets.server.ts @@ -17,11 +17,9 @@ interface CliTicket { authorized: boolean } -// The Vite RSC plugin duplicates .server.ts modules — one copy ends up in the -// regular server bundle (used by route handlers) and another in the RSC server -// bundle (used by createServerFn). Each copy gets its own module-level state, -// which would silently fragment the ticket store. Pin the Map to globalThis so -// both copies share a single instance. +// Server handlers and server-function handlers can load this module through +// different bundled entry points. Pin the Map to globalThis so those callers +// share one ticket store instead of fragmenting module-level state. const TICKETS_KEY = Symbol.for('tanstack.cli-auth.tickets') const TICKETS_INTERVAL_KEY = Symbol.for('tanstack.cli-auth.cleanup') diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index ab2ea0762..15227b243 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -1,7 +1,7 @@ import { Link } from '@tanstack/react-router' import { ChevronDown } from 'lucide-react' import { twMerge } from 'tailwind-merge' -import type { MarkdownHeading } from '~/utils/markdown/processor.rsc' +import type { MarkdownHeading } from '~/utils/markdown' import { Dropdown, DropdownTrigger, diff --git a/src/components/CodeExplorer.tsx b/src/components/CodeExplorer.tsx index ba96542a9..773b37b5a 100644 --- a/src/components/CodeExplorer.tsx +++ b/src/components/CodeExplorer.tsx @@ -5,11 +5,13 @@ import { CodeExplorerTopBar } from './CodeExplorerTopBar' import type { GitHubFileNode } from '~/utils/documents.server' import type { Library } from '~/libraries' import { twMerge } from 'tailwind-merge' +import { CodeBlock } from '~/components/markdown' +import { getCodeBlockLanguageFromFilePath } from '~/components/markdown/codeBlock.shared' interface CodeExplorerProps { activeTab: 'code' | 'sandbox' codeSandboxUrl: string - currentCodeRsc: React.ReactNode + currentCode: string currentPath: string examplePath: string githubContents: GitHubFileNode[] | undefined @@ -23,7 +25,7 @@ interface CodeExplorerProps { export function CodeExplorer({ activeTab, codeSandboxUrl, - currentCodeRsc, + currentCode, currentPath, examplePath, githubContents, @@ -35,6 +37,7 @@ export function CodeExplorer({ }: CodeExplorerProps) { const [isFullScreen, setIsFullScreen] = React.useState(false) const [isSidebarOpen, setIsSidebarOpen] = React.useState(true) + const currentCodeLanguage = getCodeBlockLanguageFromFilePath(currentPath) // Add escape key handler React.useEffect(() => { @@ -93,7 +96,15 @@ export function CodeExplorer({ isFullScreen ? 'max-h-[90dvh]' : 'max-h-[80dvh]', )} > - {currentCodeRsc} + + + {currentCode} + + } - > - - - ) -} - -function DeferredApplicationStarterFallback({ - mode = 'full', -}: { - mode?: ApplicationStarterProps['mode'] -}) { - if (mode === 'compact') { - return ( -