From a852fb3957c41698e0febf1cebbee91db32bd1e8 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 21 Jul 2026 06:20:39 +0300 Subject: [PATCH 1/9] fix(diagram): fix arrow positioning and diagram readiness Map diagram endpoints explicitly and wait for layout stabilization so arrows render against final coordinates and remain correctly positioned in print output --- src/components/diagram/Arrow.tsx | 12 +++-- src/components/diagram/Diagram.tsx | 86 +++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/components/diagram/Arrow.tsx b/src/components/diagram/Arrow.tsx index aae4286..6183161 100644 --- a/src/components/diagram/Arrow.tsx +++ b/src/components/diagram/Arrow.tsx @@ -54,7 +54,11 @@ export function variantProps(variant: ArrowVariant): VariantProps { } } -export interface ArrowProps extends Omit { +export interface ArrowProps extends Omit { + /** Source endpoint: a BoxNode id (from the Diagram `id()` factory). */ + from: string; + /** Target endpoint: a BoxNode id (from the Diagram `id()` factory). */ + to: string; /** Preset style bundle. Individual props below override the preset. */ variant?: ArrowVariant; color?: string; @@ -71,8 +75,10 @@ export interface ArrowProps extends Omit { /** * A diagram arrow. Thin wrapper over react-xarrows `Xarrow` that applies a * named `variant` preset; any explicitly-passed prop overrides the preset. + * `from`/`to` map to Xarrow's required `start`/`end` — Xarrow silently anchors + * missing endpoints at (0,0), so the mapping must happen here. */ -export default function Arrow({ variant = 'primary', ...overrides }: ArrowProps) { +export default function Arrow({ variant = 'primary', from, to, ...overrides }: ArrowProps) { const props = { ...variantProps(variant), ...overrides }; - return ; + return )} />; } diff --git a/src/components/diagram/Diagram.tsx b/src/components/diagram/Diagram.tsx index eaa0bd9..a9c1d4e 100644 --- a/src/components/diagram/Diagram.tsx +++ b/src/components/diagram/Diagram.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useId, useRef, useState } from 'react'; +import { Xwrapper, useXarrow } from 'react-xarrows'; import { COLORS, type DiagramColors } from './colors'; /** Scoped id factory: `id('source')` → a page-unique id for an arrow endpoint. */ @@ -19,14 +20,70 @@ export interface DiagramProps { const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; +/** Frames the root rect must hold still before the diagram counts as settled. */ +const STABLE_FRAMES = 10; +/** Upper bound on settling; after this many frames the diagram is captured as-is. */ +const MAX_FRAMES = 600; + +/** + * Re-draws the arrows every animation frame until the diagram's root rect has + * been stable for STABLE_FRAMES frames, then reports settled. react-xarrows + * measures rects only when triggered, so late layout shifts (webfont loads, + * dev-server CSS injection) would otherwise freeze arrows at stale coordinates. + * Must render inside for useXarrow's update context. + */ +function ArrowSettler({ + rootRef, + onSettled, +}: { + rootRef: React.RefObject; + onSettled: () => void; +}) { + const updateArrows = useXarrow(); + const updateRef = useRef(updateArrows); + updateRef.current = updateArrows; + + useEffect(() => { + let raf = 0; + let stable = 0; + let frames = 0; + let last = ''; + const tick = () => { + const el = rootRef.current; + if (!el) return; + frames += 1; + const r = el.getBoundingClientRect(); + const sig = `${r.x},${r.y},${r.width},${r.height}`; + if (sig === last) { + stable += 1; + } else { + stable = 0; + last = sig; + } + updateRef.current(); + if (stable >= STABLE_FRAMES || frames >= MAX_FRAMES) { + onSettled(); + return; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + /** * Container for a node-and-arrow diagram. * * react-xarrows measures DOM rects, so arrows only render in a browser. Under * SSR the children are skipped and a placeholder is emitted; the live render * path (Vite dev server + Puppeteer) mounts this in a real browser, where the - * arrows draw. Once they have, the root gets `data-facet-ready="true"` so the - * snapshotter knows the diagram has settled before capturing. + * arrows draw. Once the layout has settled and the arrows have been redrawn + * against it, the root gets `data-facet-ready="true"` so the snapshotter knows + * the diagram is final before capturing. */ export default function Diagram({ children, colors = COLORS, className }: DiagramProps) { const prefix = useId(); @@ -34,21 +91,6 @@ export default function Diagram({ children, colors = COLORS, className }: Diagra const rootRef = useRef(null); const [ready, setReady] = useState(false); - // Arrows are drawn by react-xarrows in a post-mount effect. Wait two animation - // frames after our own mount so those arrow SVGs exist in the DOM before we - // signal readiness to the snapshotter. - useEffect(() => { - if (!isBrowser) return; - let raf2 = 0; - const raf1 = requestAnimationFrame(() => { - raf2 = requestAnimationFrame(() => setReady(true)); - }); - return () => { - cancelAnimationFrame(raf1); - cancelAnimationFrame(raf2); - }; - }, []); - if (!isBrowser) { return
; } @@ -59,9 +101,15 @@ export default function Diagram({ children, colors = COLORS, className }: Diagra className={className} data-facet-diagram data-facet-ready={ready ? 'true' : 'false'} - style={{ ['--facet-diagram-primary' as string]: colors.primary }} + // position:relative makes this container the offset parent for the + // absolutely-positioned react-xarrows SVGs, so the arrows paginate with + // the diagram instead of anchoring to the page body in print output. + style={{ position: 'relative', ['--facet-diagram-primary' as string]: colors.primary }} > - {children(id)} + + {children(id)} + setReady(true)} /> +
); } From 56f60649ee01ce3cbe0949f502faf5feb344a505 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 21 Jul 2026 07:27:37 +0300 Subject: [PATCH 2/9] fix(cli): Fix live template and MDX rendering Ensure @live templates render through the browser path automatically and raw MDX is transformed before React in dev mode. Prevents incorrect static rendering and React parse failures. --- cli/src/builders/facet-directory.ts | 14 ++++++++++---- cli/src/generators/html.ts | 3 ++- cli/src/server/routes.ts | 9 ++------- cli/src/utils/live-template.test.ts | 19 +++++++++++++++++++ cli/src/utils/live-template.ts | 13 +++++++++++++ cli/test/fixtures/live-template.tsx | 5 +++++ cli/test/fixtures/standard-template.tsx | 5 +++++ 7 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 cli/src/utils/live-template.test.ts create mode 100644 cli/src/utils/live-template.ts create mode 100644 cli/test/fixtures/live-template.tsx create mode 100644 cli/test/fixtures/standard-template.tsx diff --git a/cli/src/builders/facet-directory.ts b/cli/src/builders/facet-directory.ts index 9050dcd..27da204 100644 --- a/cli/src/builders/facet-directory.ts +++ b/cli/src/builders/facet-directory.ts @@ -613,10 +613,16 @@ import { resolve } from 'path';${imports} export default defineConfig({ plugins: [ - mdx({ - remarkPlugins: ${remarkArray},${rehypeLine} - include: ['**/*.md', '**/*.mdx'], - }), + // enforce: 'pre' — in dev mode plugin-react's transform runs before + // array-ordered plugins, so MDX must be hoisted to the pre stage or + // react-babel parses raw .mdx and fails. Build mode respects array order. + { + enforce: 'pre', + ...mdx({ + remarkPlugins: ${remarkArray},${rehypeLine} + include: ['**/*.md', '**/*.mdx'], + }), + }, react({ include: /\\.(md|mdx|js|jsx|ts|tsx)$/, }), diff --git a/cli/src/generators/html.ts b/cli/src/generators/html.ts index 9f24fb8..b5edce7 100644 --- a/cli/src/generators/html.ts +++ b/cli/src/generators/html.ts @@ -12,6 +12,7 @@ import { combineHTMLAndCSS } from '../bundler/renderer.js'; import { scopeHTML } from '../utils/css-scoper.js'; import { parseRemoteRef, resolveRemoteRef } from '../utils/remote-resolver.js'; import { runTailwindCached } from '../utils/tailwind.js'; +import { shouldUseLiveRendering } from '../utils/live-template.js'; function findProjectRoot(templatePath: string): string | undefined { const absTemplate = resolve(templatePath); @@ -86,7 +87,7 @@ export async function generateHTML(options: GenerateOptions): Promise { // Live render path: boot a Vite dev server, render in a real browser, snapshot // the settled DOM. Required for DOM-measuring components (diagrams). - if (options.live) { + if (shouldUseLiveRendering(options.live, resolve(consumerRoot ?? process.cwd(), templatePath))) { logger.info('Live rendering template in browser...'); const server = await startViteServer({ templatePath, data, consumerRoot, logger }); try { diff --git a/cli/src/server/routes.ts b/cli/src/server/routes.ts index dd03ec8..6fa1522 100644 --- a/cli/src/server/routes.ts +++ b/cli/src/server/routes.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readdir, rm, stat, writeFile } from 'fs/promises'; import { createHash } from 'node:crypto'; import { join, basename, resolve } from 'path'; import { tmpdir } from 'os'; -import { createReadStream, rmSync, lstatSync, readFileSync } from 'fs'; +import { createReadStream, rmSync, lstatSync } from 'fs'; import { Readable } from 'node:stream'; import type { ServerConfig } from './config.js'; @@ -23,6 +23,7 @@ import { combineHTMLAndCSS } from '../bundler/renderer.js'; import { generatePDFBuffer } from '../utils/pdf-generator.js'; import { applyPDFSecurity } from '../utils/pdf-security.js'; import { Logger } from '../utils/logger.js'; +import { isLiveTemplate } from '../utils/live-template.js'; import { runTailwindCached } from '../utils/tailwind.js'; import { RenderProgress } from './render-stream.js'; import { computeCacheKey, RenderCache } from './render-cache.js'; @@ -404,12 +405,6 @@ function cleanupTempDir(tempDir: string): void { rmSync(tempDir, { recursive: true, force: true }); } -function isLiveTemplate(templatePath: string): boolean { - const source = readFileSync(templatePath, 'utf-8').slice(0, 4096); - const firstLine = source.split(/\r?\n/).find((line) => line.trim() !== ''); - return /^\s*\/\/\s*@live\b/.test(firstLine ?? ''); -} - async function renderHTML( consumerRoot: string, entryFile: string, diff --git a/cli/src/utils/live-template.test.ts b/cli/src/utils/live-template.test.ts new file mode 100644 index 0000000..a745613 --- /dev/null +++ b/cli/src/utils/live-template.test.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { shouldUseLiveRendering } from './live-template.js'; + +const fixture = (name: string) => fileURLToPath(new URL(`../../test/fixtures/${name}`, import.meta.url)); + +describe('shouldUseLiveRendering', () => { + it('enables live rendering when requested explicitly', () => { + expect(shouldUseLiveRendering(true, fixture('standard-template.tsx'))).toBe(true); + }); + + it('enables live rendering when the first non-empty line is // @live', () => { + expect(shouldUseLiveRendering(false, fixture('live-template.tsx'))).toBe(true); + }); + + it('does not enable live rendering for a later // @live comment', () => { + expect(shouldUseLiveRendering(false, fixture('standard-template.tsx'))).toBe(false); + }); +}); diff --git a/cli/src/utils/live-template.ts b/cli/src/utils/live-template.ts new file mode 100644 index 0000000..0c72e77 --- /dev/null +++ b/cli/src/utils/live-template.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; + +export function isLiveTemplate(templatePath: string): boolean { + const firstLine = readFileSync(templatePath, 'utf-8') + .slice(0, 4096) + .split(/\r?\n/) + .find((line) => line.trim() !== ''); + return /^\s*\/\/\s*@live\b/.test(firstLine ?? ''); +} + +export function shouldUseLiveRendering(explicitLive: boolean | undefined, templatePath: string): boolean { + return explicitLive || isLiveTemplate(templatePath); +} diff --git a/cli/test/fixtures/live-template.tsx b/cli/test/fixtures/live-template.tsx new file mode 100644 index 0000000..669f60e --- /dev/null +++ b/cli/test/fixtures/live-template.tsx @@ -0,0 +1,5 @@ + +// @live +export default function LiveTemplate() { + return null; +} diff --git a/cli/test/fixtures/standard-template.tsx b/cli/test/fixtures/standard-template.tsx new file mode 100644 index 0000000..2527536 --- /dev/null +++ b/cli/test/fixtures/standard-template.tsx @@ -0,0 +1,5 @@ +export default function StandardTemplate() { + return null; +} + +// @live is only a directive when it is the first non-empty line. From c57cf9ed79d09746cc187133b10700bdb4159e14 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 22 Jul 2026 15:27:03 +0300 Subject: [PATCH 3/9] feat(cli): add shared module store diagnostics Add doctor coverage for the shared Facet module store and Tailwind package/plugin layout. This surfaces missing or incompatible dependencies and supports repair through `facet doctor --skip-modules --fix`. --- cli/src/commands/doctor.test.ts | 1 + cli/src/commands/doctor.ts | 75 +++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/doctor.test.ts b/cli/src/commands/doctor.test.ts index 83a8882..0be511b 100644 --- a/cli/src/commands/doctor.test.ts +++ b/cli/src/commands/doctor.test.ts @@ -43,6 +43,7 @@ describe('runDoctor JSON contract', () => { 'facet-package-path', 'facet-version', 'npmrc-leakage', + 'global-modules', ]; expect(ids).toEqual(expected); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index f38ec10..073a268 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -16,9 +16,9 @@ import semver from 'semver'; import { Logger } from '../utils/logger.js'; import { resolvePackageManager } from '../utils/package-manager.js'; import { resolveChromePath } from '../utils/pdf-generator.js'; -import { resolveTailwindBin, tailwindBinExists } from '../utils/tailwind.js'; import { VERSION } from '../version-generated.js'; import { assetPath } from '../utils/assets.js'; +import { ensureGlobalModuleStore, inspectGlobalModuleStore } from '../bundler/module-store.js'; type CheckStatus = 'pass' | 'warn' | 'fail'; @@ -35,6 +35,7 @@ interface DoctorOptions { verbose: boolean; json: boolean; fix?: boolean; + skipModules?: boolean; } const CRITICAL_NATIVE_PACKAGES = [ @@ -125,8 +126,13 @@ export async function preflight( export async function runDoctor(options: DoctorOptions): Promise { const logger = new Logger(options.verbose); let results = await runAllChecks(options.consumerRoot); + results.push(checkGlobalModuleStore(options.skipModules)); if (options.fix) { + const globalIndex = results.findIndex(result => result.id === 'global-modules'); + if (options.skipModules && globalIndex >= 0 && results[globalIndex].status === 'fail') { + results[globalIndex] = await fixGlobalModuleStore(logger, options.skipModules); + } results = await applyFixes(results, options.consumerRoot, logger); } @@ -139,6 +145,44 @@ export async function runDoctor(options: DoctorOptions): Promise { return results.some(r => r.status === 'fail') ? 1 : 0; } +function embeddedFacetPackage(): Record { + return JSON.parse(readFileSync(assetPath('package.json'), 'utf-8')); +} + +function checkGlobalModuleStore(skipModules = false): CheckResult { + try { + const store = inspectGlobalModuleStore({ facetVersion: VERSION, facetPackage: embeddedFacetPackage() }); + return { + id: 'global-modules', + name: 'Shared Facet modules', + status: 'pass', + message: `${store.root}${skipModules ? ' (active)' : ''}`, + }; + } catch (error) { + return { + id: 'global-modules', + name: 'Shared Facet modules', + status: skipModules ? 'fail' : 'warn', + message: error instanceof Error ? error.message : String(error), + hint: 'Run `facet doctor --skip-modules --fix` to install the immutable module entry for this Facet and Node version.', + }; + } +} + +async function fixGlobalModuleStore(logger: Logger, skipModules = false): Promise { + try { + await ensureGlobalModuleStore({ facetVersion: VERSION, facetPackage: embeddedFacetPackage(), logger }); + return checkGlobalModuleStore(skipModules); + } catch (error) { + return { + id: 'global-modules', + name: 'Shared Facet modules', + status: 'fail', + message: error instanceof Error ? error.message : String(error), + }; + } +} + async function applyFixes(results: CheckResult[], consumerRoot: string, logger: Logger): Promise { const fixed = [...results]; for (let i = 0; i < fixed.length; i++) { @@ -201,6 +245,8 @@ async function tryFix(id: string, consumerRoot: string, logger: Logger): Promise } return await runCheckById('npmrc-leakage', consumerRoot); } + case 'global-modules': + return undefined; default: // node-version, architecture, facet-package-path, facet-version: not auto-fixable. logger.warn(`[fix] ${id}: manual fix required`); @@ -581,15 +627,28 @@ async function checkTsx(): Promise { function checkTailwindBin(consumerRoot: string): CheckResult { const facetRoot = join(consumerRoot, '.facet'); - const bin = resolveTailwindBin(facetRoot); - if (tailwindBinExists(facetRoot)) { - return { id: 'tailwindcss', name: 'tailwindcss (.facet bin)', status: 'pass', message: bin }; + const packagePath = join(facetRoot, 'node_modules', 'tailwindcss', 'package.json'); + if (!existsSync(packagePath)) { + return { + id: 'tailwindcss', + name: 'tailwindcss (.facet package)', + status: 'warn', + message: `not present at ${packagePath}`, + hint: 'Run a render to populate `.facet/node_modules/`, or `cd .facet && pnpm install`.', + }; + } + const version = JSON.parse(readFileSync(packagePath, 'utf-8')).version as string; + const major = Number(version.split('.')[0]); + if (major === 3) return { id: 'tailwindcss', name: 'tailwindcss', status: 'pass', message: version }; + const vitePlugin = join(facetRoot, 'node_modules', '@tailwindcss', 'vite', 'package.json'); + if (major === 4 && existsSync(vitePlugin)) { + return { id: 'tailwindcss', name: 'tailwindcss + @tailwindcss/vite', status: 'pass', message: version }; } return { id: 'tailwindcss', - name: 'tailwindcss (.facet bin)', - status: 'warn', - message: `not present at ${bin}`, - hint: 'Run a render to populate `.facet/node_modules/`, or `cd .facet && pnpm install`.', + name: 'tailwindcss', + status: 'fail', + message: major === 4 ? `v${version} without @tailwindcss/vite` : `unsupported v${version}`, + hint: major === 4 ? 'Install @tailwindcss/vite at the same version as tailwindcss.' : 'Use Tailwind CSS v3 or v4.', }; } From 6ab91ffe225fb951c0b81ec3d55fcf4883371ed4 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 23 Jul 2026 10:09:25 +0300 Subject: [PATCH 4/9] perf(cli): Optimize Facet module installs and template rendering Add immutable shared module stores, persistent build-key digests, Tailwind v3/v4 CSS handling, filtered Markdown transforms, and low-priority subprocess execution to reduce repeated install, scan, and render costs. Add skip-modules support and improve cache invalidation, diagnostics, and loader cleanup. BREAKING CHANGE: Replace the positional computeTemplateBuildKey API and remove FacetDirectory.needsInstall and isInstallBroken; callers must use the new options-based and module-preparation APIs. --- cli/src/builders/facet-directory.test.ts | 232 +++++---- cli/src/builders/facet-directory.ts | 458 ++++++++++-------- cli/src/builders/remark-config.test.ts | 18 +- cli/src/builders/remark-config.ts | 15 +- cli/src/bundler/build-cache.test.ts | 115 ++++- cli/src/bundler/build-cache.ts | 120 ++++- cli/src/bundler/module-store.test.ts | 293 ++++++++++++ cli/src/bundler/module-store.ts | 576 +++++++++++++++++++++++ cli/src/bundler/vite-builder.ts | 230 +++++---- cli/src/bundler/vite-server.ts | 31 +- cli/src/commands/doctor.ts | 2 +- cli/src/loaders/css.ts | 100 ++++ cli/src/loaders/ssr.ts | 76 ++- cli/src/server/config.test.ts | 5 + cli/src/server/config.ts | 3 + cli/src/server/preview.ts | 13 + cli/src/server/template-workspaces.ts | 2 +- cli/src/utils/tailwind-v4.test.ts | 62 +++ cli/src/utils/tailwind.test.ts | 142 +++--- cli/src/utils/tailwind.ts | 218 +++++---- 20 files changed, 2114 insertions(+), 597 deletions(-) create mode 100644 cli/src/bundler/module-store.test.ts create mode 100644 cli/src/bundler/module-store.ts create mode 100644 cli/src/loaders/css.ts create mode 100644 cli/src/utils/tailwind-v4.test.ts diff --git a/cli/src/builders/facet-directory.test.ts b/cli/src/builders/facet-directory.test.ts index cb7db2f..1f5ba2f 100644 --- a/cli/src/builders/facet-directory.test.ts +++ b/cli/src/builders/facet-directory.test.ts @@ -89,71 +89,26 @@ async function writeLocalFacetPackage(options: { scripts?: Record { - it('returns true when node_modules is missing', async () => { - await writeFile(join(facetRoot, 'package.json'), '{}'); - expect(newFacetDir().needsInstall()).toBe(true); - }); - - it('returns true when node_modules is empty', async () => { - await writeFile(join(facetRoot, 'package.json'), '{}'); - await mkdir(join(facetRoot, 'node_modules')); - expect(newFacetDir().needsInstall()).toBe(true); - }); - - it('returns true when a sentinel package is missing', async () => { - await writeFile(join(facetRoot, 'package.json'), '{}'); - await writeSentinels(['react', 'vite']); // missing @vitejs/plugin-react and @flanksource/facet - expect(newFacetDir().needsInstall()).toBe(true); - }); - - it('returns true when package.json mtime is newer than node_modules', async () => { - await writeSentinels(); - await writeFile(join(facetRoot, 'package.json'), '{}'); - const future = new Date(Date.now() + 60_000); - utimesSync(join(facetRoot, 'package.json'), future, future); - const past = new Date(Date.now() - 60_000); - utimesSync(join(facetRoot, 'node_modules'), past, past); - expect(newFacetDir().needsInstall()).toBe(true); - }); - - it('returns false when sentinels exist and node_modules is newer than package.json', async () => { - await writeFile(join(facetRoot, 'package.json'), '{}'); - await writeSentinels(); - const past = new Date(Date.now() - 60_000); - utimesSync(join(facetRoot, 'package.json'), past, past); - const future = new Date(Date.now() + 60_000); - utimesSync(join(facetRoot, 'node_modules'), future, future); - expect(newFacetDir().needsInstall()).toBe(false); - }); -}); - -describe('FacetDirectory.isInstallBroken', () => { - it('returns true when node_modules is missing', () => { - expect(newFacetDir().isInstallBroken()).toBe(true); - }); - - it('returns true when node_modules is empty', async () => { - await mkdir(join(facetRoot, 'node_modules')); - expect(newFacetDir().isInstallBroken()).toBe(true); - }); - - it('returns true when react/package.json is missing', async () => { - await writeSentinels(['vite', '@vitejs/plugin-react', '@flanksource/facet']); - expect(newFacetDir().isInstallBroken()).toBe(true); - }); - - it('returns true when a sentinel symlink is dangling', async () => { - await writeSentinels(['react', '@vitejs/plugin-react', '@flanksource/facet']); - const viteDir = join(facetRoot, 'node_modules', 'vite'); - await mkdir(viteDir, { recursive: true }); - symlinkSync('/nonexistent-target', join(viteDir, 'package.json')); - expect(newFacetDir().isInstallBroken()).toBe(true); - }); - - it('returns false when all sentinels exist as real files', async () => { - await writeSentinels(); - expect(newFacetDir().isInstallBroken()).toBe(false); +describe('FacetDirectory.symlinkConsumerFiles', () => { + it('skips cache/tool entries, prunes stale links, and keeps .facet-fragments', async () => { + for (const dir of ['.pnpm-store', '.wrangler', '.tmp', 'npm-dist', '.facet-fragments', 'src']) { + await mkdir(join(consumerRoot, dir), { recursive: true }); + } + const facetSrc = join(facetRoot, 'src'); + await mkdir(facetSrc, { recursive: true }); + // Stale link from a facet version that mirrored everything. + symlinkSync(join(consumerRoot, '.pnpm-store'), join(facetSrc, '.pnpm-store'), 'junction'); + + const dir = newFacetDir(); + dir.create(); + dir.symlinkConsumerFiles(); + + expect(existsSync(join(facetSrc, '.pnpm-store'))).toBe(false); + expect(existsSync(join(facetSrc, '.wrangler'))).toBe(false); + expect(existsSync(join(facetSrc, '.tmp'))).toBe(false); + expect(existsSync(join(facetSrc, 'npm-dist'))).toBe(false); + expect(lstatSync(join(facetSrc, '.facet-fragments')).isSymbolicLink()).toBe(true); + expect(lstatSync(join(facetSrc, 'src')).isSymbolicLink()).toBe(true); }); }); @@ -187,23 +142,8 @@ describe('FacetDirectory.nukeInstall', () => { }); }); -describe('FacetDirectory.isInstallBroken foreign markers', () => { - it('flags installs with a sibling package-lock.json as broken', async () => { - await writeSentinels(); - await writeFile(join(facetRoot, 'package-lock.json'), '{}'); - expect(newFacetDir().isInstallBroken()).toBe(true); - }); - - it('flags symlinked node_modules as broken', async () => { - const externalCache = await mkdtemp(join(tmpdir(), 'facet-cache-')); - symlinkSync(externalCache, join(facetRoot, 'node_modules'), 'junction'); - expect(newFacetDir().isInstallBroken()).toBe(true); - await rm(externalCache, { recursive: true, force: true }); - }); -}); - describe('FacetDirectory.generateViteConfig remark plugins', () => { - it('keeps remark-frontmatter + remark-gfm defaults and appends frontmatter plugins', async () => { + it('keeps Markdown extension defaults and appends frontmatter plugins', async () => { const dir = new FacetDirectory({ consumerRoot, templateFile: 'doc.mdx', @@ -214,15 +154,94 @@ describe('FacetDirectory.generateViteConfig remark plugins', () => { const config = await readFile(join(facetRoot, 'vite.config.ts'), 'utf-8'); expect(config).toContain("import remarkFrontmatter from 'remark-frontmatter';"); expect(config).toContain(`import _remarkPlugin0 from "${join(consumerRoot, 'remark-financial-table.ts')}";`); - expect(config).toContain('remarkPlugins: [remarkFrontmatter, remarkGfm, _remarkPlugin0]'); - expect(config).toContain('rehypePlugins: [_rehypePlugin0]'); + expect(config).toContain("remarkPlugins: [remarkFrontmatter, remarkGfm, [remarkAlert, { tagName: 'blockquote' }], _remarkPlugin0]"); + expect(config).toContain( + "rehypePlugins: [[rehypeRaw, { passThrough: ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'] }], _rehypePlugin0]", + ); }); it('emits only the always-on defaults when no frontmatter plugins are declared', async () => { newFacetDir().generateViteConfig(); const config = await readFile(join(facetRoot, 'vite.config.ts'), 'utf-8'); - expect(config).toContain('remarkPlugins: [remarkFrontmatter, remarkGfm]'); - expect(config).not.toContain('rehypePlugins:'); + expect(config).toContain("remarkPlugins: [remarkFrontmatter, remarkGfm, [remarkAlert, { tagName: 'blockquote' }]]"); + expect(config).toContain( + "rehypePlugins: [[rehypeRaw, { passThrough: ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'] }]]", + ); + }); + + it('transforms consumer Markdown before Vite parses files outside the generated .facet directory', async () => { + const dir = newFacetDir(); + dir.generateViteConfig(); + dir.generateClientScaffold({}); + + for (const config of await Promise.all([ + readFile(join(facetRoot, 'vite.config.ts'), 'utf-8'), + readFile(join(facetRoot, 'vite.client.config.ts'), 'utf-8'), + ])) { + expect(config).toContain("enforce: 'pre'"); + expect(config).toContain('include: [/\\.(md|mdx)$/]'); + } + }); +}); + +describe('FacetDirectory Tailwind integration', () => { + it('loads Facet defaults before template-owned consumer CSS', async () => { + await mkdir(join(consumerRoot, 'src/styles'), { recursive: true }); + await writeFile(join(consumerRoot, 'template.tsx'), "import './src/styles/report.css';\nexport default function Template() { return null; }\n"); + await writeFile(join(consumerRoot, 'src/styles/report.css'), '@import "tailwindcss";\n'); + const dir = newFacetDir(); + dir.create(); + dir.symlinkConsumerFiles(); + dir.copyStylesCss(); + dir.generateEntryWrapper(); + + const entry = await readFile(join(facetRoot, 'entry.tsx'), 'utf-8'); + const facetImport = entry.indexOf("import './facet.css';"); + const templateImport = entry.indexOf("import Template from './src/template.tsx';"); + expect(facetImport).toBeGreaterThanOrEqual(0); + expect(templateImport).toBeGreaterThan(facetImport); + + const facetCss = await readFile(join(facetRoot, 'facet.css'), 'utf-8'); + expect(facetCss).toMatch(/^@import ['"]https:\/\/fonts\.googleapis\.com\//); + expect(facetCss).toContain('@layer facet, theme, base, components, utilities;'); + expect(facetCss).toContain('@layer facet {'); + expect(facetCss).not.toContain('layer(facet)'); + + const postProcessCss = await readFile(join(facetRoot, 'post-process.css'), 'utf-8'); + expect(postProcessCss.indexOf("@import './facet.css';")) + .toBeLessThan(postProcessCss.indexOf("@import './src/src/styles/report.css';")); + expect(postProcessCss).not.toContain('@source'); + + const postProcessV4Css = await readFile(join(facetRoot, 'post-process-v4.css'), 'utf-8'); + expect(postProcessV4Css).toContain('@import "tailwindcss/theme.css" layer(theme);'); + expect(postProcessV4Css).toContain('@import "tailwindcss/utilities.css" layer(utilities) source(none);'); + expect(postProcessV4Css).not.toContain('preflight.css'); + expect(postProcessV4Css).toContain('@source "./src/template.tsx";'); + expect(postProcessV4Css).toContain('@source "./rendered-content.html";'); + }); + + it('generates Vite configs that select the installed Tailwind major', async () => { + const dir = newFacetDir(); + dir.generateViteConfig(); + dir.generateClientScaffold({}); + + expect(await readFile(join(facetRoot, 'vite.config.ts'), 'utf-8')) + .toContain("await import('@tailwindcss/vite')"); + expect(await readFile(join(facetRoot, 'vite.client.config.ts'), 'utf-8')) + .toContain("await import('@tailwindcss/vite')"); + expect(await readFile(join(facetRoot, 'postcss.config.js'), 'utf-8')) + .toContain("tailwindMajor === 3"); + }); + + it('scans the template top-level source directory', async () => { + await mkdir(join(consumerRoot, 'src'), { recursive: true }); + await writeFile(join(consumerRoot, 'src/template.tsx'), 'export default null;\n'); + const dir = new FacetDirectory({ consumerRoot, templateFile: 'src/template.tsx', logger }); + dir.create(); + dir.copyStylesCss(); + + expect(await readFile(join(facetRoot, 'post-process-v4.css'), 'utf-8')) + .toContain('@source "./src/src";'); }); }); @@ -233,6 +252,43 @@ describe('FacetDirectory.generatePackageJson .npmrc', () => { const npmrc = await readFile(join(facetRoot, '.npmrc'), 'utf-8'); expect(npmrc).toContain('confirm-modules-purge=false'); }); + + it('uses only the pinned default manifest in skip-modules mode', async () => { + await writeFile(join(consumerRoot, 'package.json'), JSON.stringify({ + name: 'consumer', + dependencies: { 'custom-module': '2.0.0' }, + packageManager: 'npm@11.0.0', + })); + process.env.FACET_PACKAGE_PATH = join(consumerRoot, 'mutable-facet-checkout'); + const directory = new FacetDirectory({ + consumerRoot, + templateFile: 'template.tsx', + logger, + skipModules: true, + }); + + await directory.generatePackageJson(); + + const generated = JSON.parse(await readFile(join(facetRoot, 'package.json'), 'utf-8')); + expect(generated.name).toBe('.facet-default-modules'); + expect(generated.dependencies['@flanksource/facet']).toBe('0.1.59'); + expect(generated.dependencies['custom-module']).toBeUndefined(); + expect(await readFile(join(facetRoot, '.npmrc'), 'utf-8')).not.toContain('Inherited from consumer'); + }); + + it('preserves source symlink paths so skip mode resolves from shared modules', async () => { + const directory = new FacetDirectory({ + consumerRoot, + templateFile: 'template.tsx', + logger, + skipModules: true, + }); + directory.generateViteConfig(); + directory.generateClientScaffold({}); + + expect(await readFile(join(facetRoot, 'vite.config.ts'), 'utf-8')).toContain('preserveSymlinks: true'); + expect(await readFile(join(facetRoot, 'vite.client.config.ts'), 'utf-8')).toContain('preserveSymlinks: true'); + }); }); describe('FACET_PACKAGE_PATH local directory override', () => { @@ -245,15 +301,17 @@ describe('FACET_PACKAGE_PATH local directory override', () => { expect(resolveFacetPackageOverride(tarball)).toEqual({ kind: 'tarball', path: tarball }); }); - it('copies default styles from the local checkout', async () => { + it('imports compiled default styles from the local checkout', async () => { const localRoot = await writeLocalFacetPackage(); process.env.FACET_PACKAGE_PATH = localRoot; + await writeFile(join(consumerRoot, 'template.tsx'), 'export default function Template() { return null; }\n'); const facetDir = newFacetDir(); facetDir.create(); facetDir.copyStylesCss(); - expect(await readFile(join(facetRoot, 'src/styles.css'), 'utf-8')).toContain('local facet styles'); + expect(await readFile(join(facetRoot, 'facet.css'), 'utf-8')) + .toContain('@layer facet {\n/* built local css */'); }); it('uses local package metadata and writes a build fingerprint into .facet/package.json', async () => { diff --git a/cli/src/builders/facet-directory.ts b/cli/src/builders/facet-directory.ts index 27da204..035c4a8 100644 --- a/cli/src/builders/facet-directory.ts +++ b/cli/src/builders/facet-directory.ts @@ -14,22 +14,24 @@ */ import { mkdirSync, existsSync, symlinkSync, writeFileSync, readdirSync, statSync, rmSync, readlinkSync, readFileSync, lstatSync, unlinkSync, chmodSync, openSync, closeSync } from 'fs'; -import { spawnSync } from 'node:child_process'; import { createHash } from 'crypto'; -import { join, relative, dirname, resolve, extname } from 'path'; +import { join, relative, dirname, resolve, extname, sep } from 'path'; import { homedir } from 'os'; import type { Logger } from '../utils/logger.js'; import { generatePluginCodegen, + rehypePluginsArray, remarkPluginsArray, EMPTY_REMARK_CONFIG, type RemarkConfig, } from './remark-config.js'; import { assetPath } from '../utils/assets.js'; +import { createDefaultModulePackageJson, defaultModuleNpmrc } from '../bundler/module-store.js'; +import { VERSION } from '../version-generated.js'; +import { LowPriorityProcessError, runLowPriority } from '../utils/subprocess-priority.js'; const rootPackageJson = assetPath('package.json'); -const stylesCss = assetPath('styles.css'); export interface FacetDirectoryOptions { /** Consumer's project root directory */ @@ -40,23 +42,40 @@ export interface FacetDirectoryOptions { logger: Logger; /** Extra remark/rehype plugins declared in the template's frontmatter. */ remarkConfig?: RemarkConfig; + /** Use the immutable shared Facet module install without consumer dependencies. */ + skipModules?: boolean; } /** * Items to skip when symlinking from consumer directory */ +// Dot-entries are skipped wholesale (except SYMLINKED_DOT_ITEMS): mirroring +// cache/tool dirs (.pnpm-store, .wrangler, .tmp, stale .facet-* staging, …) +// into .facet/src put hundreds of thousands of junk files inside the Vite +// root, which Tailwind v4's automatic source detection walks on every build +// (measured: 574k files, ~45s per build in a real consumer). const SKIP_ITEMS = new Set([ - '.facet', - '.git', 'node_modules', 'dist', + 'build', + 'coverage', + 'npm-dist', + 'dist-sea', + 'dist-playground', + 'out', '.DS_Store', - '.gitignore', - '.env', - '.env.local', 'Thumbs.db', ]); +// Dot-entries facet itself depends on: header/footer fragment builds use +// templatePath `.facet-fragments/…`, resolved through .facet/src. +const SYMLINKED_DOT_ITEMS = new Set(['.facet-fragments']); + +function shouldSkipConsumerItem(item: string): boolean { + if (SYMLINKED_DOT_ITEMS.has(item)) return false; + return SKIP_ITEMS.has(item) || item.startsWith('.'); +} + function resolveFileProtocol(version: string, pkgDir: string, _facetRoot: string): string { if (!version.startsWith('file:')) return version; // Emit an absolute path. pnpm realpath-resolves the install dir (e.g. /var → /private/var @@ -107,6 +126,28 @@ export function resolveFacetPackageOverride(raw = process.env['FACET_PACKAGE_PAT return { kind: 'tarball', path: abs }; } +export function wrapFacetStylesInLayer(css: string): string { + const imports: string[] = []; + let remaining = css; + const leadingImport = /^((?:\s|\/\*[\s\S]*?\*\/)*@import\s+(?:"[^"]*"|'[^']*'|url\([^)]*\))[^;]*;)\s*/; + for (let match = remaining.match(leadingImport); match; match = remaining.match(leadingImport)) { + imports.push(match[1].trim()); + remaining = remaining.slice(match[0].length); + } + if (/@import\s/.test(remaining)) { + throw new Error('Facet styles contain an @import after style rules; CSS imports must precede all layered rules'); + } + + return [ + ...imports, + '@layer facet, theme, base, components, utilities;', + '@layer facet {', + remaining.trim(), + '}', + '', + ].join('\n'); +} + function newestMatchingMtime(dir: string, predicate: (path: string) => boolean): number { if (!existsSync(dir)) return 0; let newest = 0; @@ -289,17 +330,13 @@ export function serializeInjectedData(data: Record): string { } export class FacetDirectory { - // Subset of build deps whose absence reliably signals a broken install: - // react/vite/the React Vite plugin/facet itself. Hits before pnpm hoists - // anything else, so a fresh-but-empty node_modules trips on the first one. - private static SENTINEL_DEPS = ['react', 'vite', '@vitejs/plugin-react', '@flanksource/facet']; - private consumerRoot: string; private facetRoot: string; private facetSrc: string; private templateFile: string; private logger: Logger; private remarkConfig: RemarkConfig; + private skipModules: boolean; constructor(options: FacetDirectoryOptions) { this.consumerRoot = options.consumerRoot; @@ -308,22 +345,24 @@ export class FacetDirectory { this.templateFile = options.templateFile; this.logger = options.logger; this.remarkConfig = options.remarkConfig ?? EMPTY_REMARK_CONFIG; + this.skipModules = options.skipModules ?? false; } /** * MDX plugin source for the generated vite configs: extra imports, the * remarkPlugins array (always-on defaults + frontmatter-declared plugins), - * and an optional rehypePlugins line. Local plugin paths resolve relative to + * and the rehypePlugins array. Local plugin paths resolve relative to * the template file's directory. */ - private mdxPluginSource(): { imports: string; remarkArray: string; rehypeLine: string } { + private mdxPluginSource(): { imports: string; remarkArray: string; rehypeArray: string } { const templateDir = dirname(resolve(this.consumerRoot, this.templateFile)); const codegen = generatePluginCodegen(this.remarkConfig, templateDir); const imports = codegen.imports.length ? '\n' + codegen.imports.join('\n') : ''; - const rehypeLine = codegen.rehypeItems.length - ? `\n rehypePlugins: [${codegen.rehypeItems.join(', ')}],` - : ''; - return { imports, remarkArray: remarkPluginsArray(codegen.remarkItems), rehypeLine }; + return { + imports, + remarkArray: remarkPluginsArray(codegen.remarkItems), + rehypeArray: rehypePluginsArray(codegen.rehypeItems), + }; } /** @@ -345,10 +384,23 @@ export class FacetDirectory { symlinkConsumerFiles(): void { this.logger.debug('Symlinking consumer files into .facet/src/'); + // Drop links a previous facet version created for now-excluded items, so + // existing .facet/ dirs stop exposing junk trees to source scanners. + for (const existing of readdirSync(this.facetSrc)) { + if (!shouldSkipConsumerItem(existing)) continue; + const stalePath = join(this.facetSrc, existing); + try { + if (lstatSync(stalePath).isSymbolicLink()) { + rmSync(stalePath, { force: true }); + this.logger.debug(`Removed stale symlink: ${existing}`); + } + } catch { /* concurrent cleanup */ } + } + const items = readdirSync(this.consumerRoot); for (const item of items) { - if (SKIP_ITEMS.has(item)) { + if (shouldSkipConsumerItem(item)) { this.logger.debug(`Skipping ${item}`); continue; } @@ -461,7 +513,7 @@ export class FacetDirectory { const entry = isMarkdown ? `import React from 'react'; -import './src/styles.css'; +import './facet.css'; import { Page } from '@flanksource/facet'; import { Icon } from '@iconify/react'; import Content from './src/${templateRelPath}'; @@ -477,7 +529,7 @@ export default function Template({ data }) { } ` : `import React from 'react'; -import './src/styles.css'; +import './facet.css'; import Template from './src/${templateRelPath}'; @@ -496,21 +548,44 @@ export default Template; generateViteConfig(): void { this.logger.debug('Generating vite.config.ts'); - const { imports, remarkArray, rehypeLine } = this.mdxPluginSource(); + const { imports, remarkArray, rehypeArray } = this.mdxPluginSource(); const config = ` import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import mdx from '@mdx-js/rollup'; import remarkGfm from 'remark-gfm'; import remarkFrontmatter from 'remark-frontmatter'; -import { resolve } from 'path';${imports} - -export default defineConfig({ - plugins: [ - mdx({ - remarkPlugins: ${remarkArray},${rehypeLine} - include: ['**/*.md', '**/*.mdx'], +import { remarkAlert } from 'remark-github-blockquote-alert'; +import rehypeRaw from 'rehype-raw'; +import { resolve } from 'path'; +import { createRequire } from 'module';${imports} + +const facetRequire = createRequire(import.meta.url); +const tailwindMajor = Number(facetRequire('tailwindcss/package.json').version.split('.')[0]); + +export default defineConfig(async () => { + const tailwindPlugins = tailwindMajor === 4 + ? [(await import('@tailwindcss/vite')).default()] + : []; + const mdxPlugin = { + enforce: 'pre', + ...mdx({ + remarkPlugins: ${remarkArray}, + rehypePlugins: ${rehypeArray}, + include: [/\\.(md|mdx)$/], }), + }; + // Rolldown crosses the native->JS boundary for every module unless a hook + // declares a filter (the plugin's own include only filters inside JS). With + // zero markdown files this dispatch tax was measured at 76% of build time. + const viteNs = await import('vite'); + if (viteNs.rolldownVersion && typeof mdxPlugin.transform === 'function') { + mdxPlugin.transform = { filter: { id: /\\.(md|mdx)$/ }, handler: mdxPlugin.transform }; + } + return { + plugins: [ + ...tailwindPlugins, + mdxPlugin, react({ include: /\\.(md|mdx|js|jsx|ts|tsx)$/, }), @@ -519,6 +594,7 @@ export default defineConfig({ // Resolve symlinks to real paths so pnpm's symlinked transitive deps stay // reachable; dedupe keeps a single React copy across the symlinked packages. dedupe: ['react', 'react-dom'], + preserveSymlinks: ${this.skipModules}, alias: { '@flanksource/facet': resolve(__dirname, 'node_modules/@flanksource/facet'), '@facet': resolve(__dirname, 'node_modules/@flanksource/facet'), @@ -529,7 +605,11 @@ export default defineConfig({ conditions: ['import', 'module', 'browser', 'default'], }, ssr: { - noExternal: ['react-icons', '@iconify/react', '@flanksource/facet', new RegExp('^@flanksource/')], + // Icon libraries are dual CJS/ESM with no CSS side effects and megabyte-scale + // generated modules; bundling them was 92% of SSR build input. They resolve + // at render time from .facet/node_modules instead (output byte-identical). + // @flanksource/facet stays bundled: externalizing it changes rendered HTML. + noExternal: ['@flanksource/facet', new RegExp('^@flanksource/(?!icons)')], resolve: { conditions: ['node', 'import', 'module', 'browser', 'default'], externalConditions: ['node'], @@ -554,6 +634,7 @@ export default defineConfig({ }, cssCodeSplit: false, }, + }; }); `; @@ -602,25 +683,37 @@ root.render(React.createElement(Template, { data })); `; writeFileSync(join(this.facetRoot, 'index.html'), indexHtml, 'utf-8'); - const { imports, remarkArray, rehypeLine } = this.mdxPluginSource(); + const { imports, remarkArray, rehypeArray } = this.mdxPluginSource(); const config = ` import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import mdx from '@mdx-js/rollup'; import remarkGfm from 'remark-gfm'; import remarkFrontmatter from 'remark-frontmatter'; -import { resolve } from 'path';${imports} - -export default defineConfig({ +import { remarkAlert } from 'remark-github-blockquote-alert'; +import rehypeRaw from 'rehype-raw'; +import { resolve } from 'path'; +import { createRequire } from 'module';${imports} + +const facetRequire = createRequire(import.meta.url); +const tailwindMajor = Number(facetRequire('tailwindcss/package.json').version.split('.')[0]); + +export default defineConfig(async () => { + const tailwindPlugins = tailwindMajor === 4 + ? [(await import('@tailwindcss/vite')).default()] + : []; + return { plugins: [ + ...tailwindPlugins, // enforce: 'pre' — in dev mode plugin-react's transform runs before // array-ordered plugins, so MDX must be hoisted to the pre stage or // react-babel parses raw .mdx and fails. Build mode respects array order. { enforce: 'pre', ...mdx({ - remarkPlugins: ${remarkArray},${rehypeLine} - include: ['**/*.md', '**/*.mdx'], + remarkPlugins: ${remarkArray}, + rehypePlugins: ${rehypeArray}, + include: [/\\.(md|mdx)$/], }), }, react({ @@ -632,6 +725,7 @@ export default defineConfig({ // (e.g. d3-array → internmap) are reachable during esbuild dep optimization. // dedupe keeps a single React copy, which preserveSymlinks otherwise guarded. dedupe: ['react', 'react-dom'], + preserveSymlinks: ${this.skipModules}, alias: { '@flanksource/facet': resolve(__dirname, 'node_modules/@flanksource/facet'), '@facet': resolve(__dirname, 'node_modules/@flanksource/facet'), @@ -641,21 +735,12 @@ export default defineConfig({ }, conditions: ['import', 'module', 'browser', 'default'], }, + }; }); `; writeFileSync(join(this.facetRoot, 'vite.client.config.ts'), config, 'utf-8'); - // The dev server processes the template's `@tailwind` directives through - // PostCSS at request time (the SSR path instead shells out to the Tailwind - // CLI). Override the autoprefixer-only postcss config so Tailwind runs. - const postcss = `export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; -`; - writeFileSync(join(this.facetRoot, 'postcss.config.js'), postcss, 'utf-8'); + this.generatePostCSSConfig(); this.logger.debug('Generated live-render client scaffold'); } @@ -699,7 +784,25 @@ export default defineConfig({ async generatePackageJson(): Promise { this.logger.debug('Generating package.json'); - const facetOverride = resolveFacetPackageOverride(); + if (this.skipModules) { + const facetPackage = JSON.parse(readFileSync(rootPackageJson, 'utf-8')); + const packageContent = JSON.stringify(createDefaultModulePackageJson({ + facetVersion: VERSION, + facetPackage, + }), null, 2); + const packagePath = join(this.facetRoot, 'package.json'); + let existingPackage = ''; + try { existingPackage = readFileSync(packagePath, 'utf-8'); } catch { /* first generation */ } + if (existingPackage !== packageContent) writeFileSync(packagePath, packageContent, 'utf-8'); + this.removePaths(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']); + const npmrcPath = join(this.facetRoot, '.npmrc'); + writeFileSync(npmrcPath, defaultModuleNpmrc(), { encoding: 'utf-8', mode: 0o600 }); + chmodSync(npmrcPath, 0o600); + this.logger.debug('Generated consumer-independent skip-modules manifest'); + return; + } + + const facetOverride = this.skipModules ? undefined : resolveFacetPackageOverride(); if (facetOverride?.kind === 'directory') { await this.ensureLocalFacetPackageBuilt(facetOverride.path); } @@ -762,6 +865,9 @@ export default defineConfig({ '@mdx-js/rollup', 'remark-gfm', 'remark-frontmatter', + 'remark-github-blockquote-alert', + 'rehype-raw', + 'mermaid', 'react-icons', 'react-xarrows', '@flanksource/icons', @@ -769,6 +875,7 @@ export default defineConfig({ 'typescript', '@tailwindcss/typography', '@tailwindcss/postcss', + '@tailwindcss/vite', 'tailwindcss', 'autoprefixer', 'postcss', @@ -996,19 +1103,6 @@ export default defineConfig({ } } - private readFacetAsset(localRelPath: string, embeddedPath: string, label: string): string { - const facetOverride = resolveFacetPackageOverride(); - if (facetOverride?.kind === 'directory') { - const localPath = join(facetOverride.path, localRelPath); - if (existsSync(localPath)) { - this.logger.debug(`Using ${label} from FACET_PACKAGE_PATH: ${localPath}`); - return readFileSync(localPath, 'utf-8'); - } - this.logger.debug(`FACET_PACKAGE_PATH has no ${localRelPath}, using embedded ${label}`); - } - return readFileSync(embeddedPath, 'utf-8'); - } - private async ensureLocalFacetPackageBuilt(packageRoot: string): Promise { const isCurrent = (): boolean => !needsLocalFacetComponentsBuild(packageRoot) && !needsLocalFacetCssBuild(packageRoot); @@ -1017,17 +1111,17 @@ export default defineConfig({ return; } - await this.withLocalFacetBuildLock(packageRoot, () => { + await this.withLocalFacetBuildLock(packageRoot, async () => { // Another process may have completed the build while this process waited. if (isCurrent()) return; const shouldBuildCss = needsLocalFacetCssBuild(packageRoot); if (needsLocalFacetComponentsBuild(packageRoot)) { - this.runLocalFacetBuildScript(packageRoot, 'build:components'); + await this.runLocalFacetBuildScript(packageRoot, 'build:components'); } // vite.lib.config.ts empties dist/ before building components. If CSS was // current before that run, it may now be missing, so re-check afterward. if (shouldBuildCss || needsLocalFacetCssBuild(packageRoot)) { - this.runLocalFacetBuildScript(packageRoot, 'build:css'); + await this.runLocalFacetBuildScript(packageRoot, 'build:css'); } }); } @@ -1102,26 +1196,29 @@ export default defineConfig({ this.logger.debug('Removed stale installed @flanksource/facet local override'); } - private runLocalFacetBuildScript(packageRoot: string, script: string): void { + private async runLocalFacetBuildScript(packageRoot: string, script: string): Promise { this.logger.info(`FACET_PACKAGE_PATH local override is stale; running pnpm run ${script}`); - const result = spawnSync('pnpm', ['run', script], { - cwd: packageRoot, - timeout: LOCAL_BUILD_TIMEOUT_MS, - maxBuffer: 50 * 1024 * 1024, - }); - const stdout = (result.stdout ?? Buffer.from('')).toString(); - const stderr = (result.stderr ?? Buffer.from('')).toString(); - if (stdout) this.logger.debug(stdout); - if (stderr) this.logger.debug(stderr); - const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT'; - if (result.error && !timedOut) throw result.error; - if (timedOut || result.status !== 0) { - const reason = timedOut + try { + const result = await runLowPriority({ + command: 'pnpm', + args: ['run', script], + options: { cwd: packageRoot }, + timeoutMs: LOCAL_BUILD_TIMEOUT_MS, + maxBufferBytes: 50 * 1024 * 1024, + }); + if (result.stdout.length > 0) this.logger.debug(result.stdout.toString()); + if (result.stderr.length > 0) this.logger.debug(result.stderr.toString()); + } catch (error) { + if (!(error instanceof LowPriorityProcessError)) throw error; + const stdout = error.stdout.toString(); + const stderr = error.stderr.toString(); + if (stdout) this.logger.debug(stdout); + if (stderr) this.logger.debug(stderr); + if (error.cause !== undefined && error.exitCode === null && !error.timedOut) throw error.cause; + const reason = error.timedOut ? `timed out after ${LOCAL_BUILD_TIMEOUT_MS / 1000}s` - : `failed with exit code ${result.status ?? 'unknown'}`; - throw new Error( - `pnpm run ${script} ${reason} in ${packageRoot}:\n${stdout}${stderr}` - ); + : `failed with exit code ${error.exitCode ?? 'unknown'}`; + throw new Error(`pnpm run ${script} ${reason} in ${packageRoot}:\n${stdout}${stderr}`); } } @@ -1156,63 +1253,6 @@ export default defineConfig({ this.logger.warn('.facet/.npmrc may contain inherited auth tokens but no ancestor .gitignore excludes `.facet/`. Add `.facet/` (or `**/.facet/` for monorepos) to .gitignore to avoid committing secrets.'); } - /** - * Returns true if node_modules needs to be installed. Combines mtime check - * with a sentinel-dep scan so partial / corrupt installs are detected. - */ - needsInstall(): boolean { - const packagePath = join(this.facetRoot, 'package.json'); - const nodeModulesPath = join(this.facetRoot, 'node_modules'); - if (!existsSync(nodeModulesPath)) return true; - if (this.isInstallBroken()) return true; - try { - return statSync(packagePath).mtimeMs > statSync(nodeModulesPath).mtimeMs; - } catch { - return true; - } - } - - /** - * Cheap O(sentinels) check for a partial/corrupt install: empty node_modules, - * missing sentinel package, or a dangling symlink count as broken. - */ - isInstallBroken(): boolean { - const nm = join(this.facetRoot, 'node_modules'); - if (!existsSync(nm)) return true; - // Foreign-manager markers: an npm/yarn lockfile next to node_modules makes - // pnpm refuse to write into the dir (ENOENT on importPackage). Treat as broken. - if (existsSync(join(this.facetRoot, 'package-lock.json'))) { - this.logger.warn('Foreign package-lock.json found in .facet/ — install broken'); - return true; - } - if (existsSync(join(this.facetRoot, 'yarn.lock'))) { - this.logger.warn('Foreign yarn.lock found in .facet/ — install broken'); - return true; - } - // node_modules pointing outside .facet/ (legacy facet-cache symlink) confuses - // pnpm's hoisted layout. Force a clean install. - try { - const st = lstatSync(nm); - if (st.isSymbolicLink()) { - this.logger.warn('Legacy .facet/node_modules symlink found — install broken'); - return true; - } - } catch { /* fall through */ } - try { if (readdirSync(nm).length === 0) return true; } catch { return true; } - for (const dep of FacetDirectory.SENTINEL_DEPS) { - const depPkg = join(nm, ...dep.split('/'), 'package.json'); - if (!existsSync(depPkg)) { - this.logger.warn(`Sentinel dep missing: ${dep} (${depPkg}) — install broken`); - return true; - } - try { statSync(depPkg); } catch { - this.logger.warn(`Sentinel dep dangling: ${dep} — install broken`); - return true; - } - } - return false; - } - /** Reset .facet/ install state so the next pnpm install re-resolves clean. */ nukeInstall(): void { // Include package-lock.json: a stale npm/yarn lockfile makes pnpm treat @@ -1266,22 +1306,21 @@ export default defineConfig({ * Generate default postcss.config.js if consumer doesn't have one */ generatePostCSSConfig(): void { - const consumerConfig = join(this.consumerRoot, 'postcss.config.js'); - - // Skip if consumer has their own config (will be symlinked) - if (existsSync(consumerConfig)) { - this.logger.debug('Consumer has postcss.config.js, skipping generation'); - return; - } - - this.logger.debug('Generating default postcss.config.js'); + this.logger.debug('Generating Tailwind-compatible postcss.config.js'); + const config = `import autoprefixer from 'autoprefixer'; +import { createRequire } from 'module'; + +const facetRequire = createRequire(import.meta.url); +const tailwindMajor = Number(facetRequire('tailwindcss/package.json').version.split('.')[0]); +const plugins = [autoprefixer()]; +if (tailwindMajor === 3) { + const tailwindcss = (await import('tailwindcss')).default; + plugins.unshift(tailwindcss(process.env.FACET_POST_PROCESS === '1' + ? './tailwind.postprocess.config.js' + : './tailwind.config.js')); +} - // Generate ESM-compatible config (not CommonJS) since package.json has "type": "module" - const config = `export default { - plugins: { - autoprefixer: {}, - }, -}; +export default { plugins }; `; writeFileSync(join(this.facetRoot, 'postcss.config.js'), config, 'utf-8'); @@ -1292,24 +1331,16 @@ export default defineConfig({ * Generate default tailwind.config.js if consumer doesn't have one */ generateTailwindConfig(): void { - const consumerConfig = join(this.consumerRoot, 'tailwind.config.js'); - - // Skip if consumer has their own config (will be symlinked) - if (existsSync(consumerConfig)) { - this.logger.debug('Consumer has tailwind.config.js, skipping generation'); - return; - } - - this.logger.debug('Generating default tailwind.config.js'); - - // Generate ESM-compatible config (not CommonJS) since package.json has "type": "module" - // Point directly to consumer root to avoid symlink issues - const config = `import typography from '@tailwindcss/typography'; -console.log('Using default tailwind.config.js'); + const consumerConfigName = ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts'] + .find((name) => existsSync(join(this.consumerRoot, name))); + const config = consumerConfigName + ? `import consumerConfig from './src/${consumerConfigName}'; +export default consumerConfig; +` + : `import typography from '@tailwindcss/typography'; export default { content: [ - "src/**/*.{html,js,jsx,ts,tsx,md,mdx}", - "./node_modules/@flanksource/facet/src/**/*.{js,jsx,ts,tsx}" + "src/**/*.{html,js,jsx,ts,tsx,md,mdx}" ], theme: { extend: { @@ -1329,8 +1360,14 @@ export default { plugins: [typography], }; `; - writeFileSync(join(this.facetRoot, 'tailwind.config.js'), config, 'utf-8'); + const postProcessConfig = `import baseConfig from './tailwind.config.js'; +export default { + ...baseConfig, + content: [...(baseConfig.content ?? []), './rendered-content.html'], +}; +`; + writeFileSync(join(this.facetRoot, 'tailwind.postprocess.config.js'), postProcessConfig, 'utf-8'); this.logger.debug('Generated tailwind.config.js'); } @@ -1338,26 +1375,75 @@ export default { * Copy embedded styles.css to .facet/src/ */ copyStylesCss(): void { - const consumerStylesCss = join(this.consumerRoot, 'src/styles.css'); + const installedStyles = join(this.facetRoot, 'node_modules/@flanksource/facet/dist/styles.css'); + const facetOverride = this.skipModules ? undefined : resolveFacetPackageOverride(); + const stylesPath = existsSync(installedStyles) + ? installedStyles + : facetOverride?.kind === 'directory' + ? join(facetOverride.path, 'dist/styles.css') + : assetPath('styles.css'); + writeFileSync( + join(this.facetRoot, 'facet.css'), + wrapFacetStylesInLayer(readFileSync(stylesPath, 'utf-8')), + 'utf-8', + ); - // Skip if consumer has their own styles.css (will be symlinked) - if (existsSync(consumerStylesCss)) { - this.logger.debug('Consumer has src/styles.css, skipping copy'); - return; - } + const source = readFileSync(join(this.consumerRoot, this.templateFile), 'utf-8'); + const templateDir = dirname(join(this.consumerRoot, this.templateFile)); + const imports = [...source.matchAll(/(?:^|\n)\s*import\s+(?:[^'"\n]+\s+from\s+)?['"]([^'"]+\.css)['"]/g)] + .map((match) => match[1]) + .map((specifier) => { + if (!specifier.startsWith('.')) return specifier; + const consumerPath = relative(this.consumerRoot, resolve(templateDir, specifier)).split(sep).join('/'); + return `./src/${consumerPath}`; + }); + const postProcessLines = [ + "@import './facet.css';", + ...imports.map((specifier) => `@import '${specifier}';`), + ]; + const postProcess = [...postProcessLines, ''].join('\n'); + const normalizedTemplateFile = this.templateFile.replaceAll('\\', '/').replace(/^\.\//, ''); + const staticSource = normalizedTemplateFile.includes('/') + ? normalizedTemplateFile.slice(0, normalizedTemplateFile.indexOf('/')) + : normalizedTemplateFile; + const postProcessV4 = [ + ...postProcessLines, + '@import "tailwindcss/theme.css" layer(theme);', + '@import "tailwindcss/utilities.css" layer(utilities) source(none);', + `@source "./src/${staticSource}";`, + '@source "./rendered-content.html";', + '', + ].join('\n'); + writeFileSync(join(this.facetRoot, 'post-process.css'), postProcess, 'utf-8'); + writeFileSync(join(this.facetRoot, 'post-process-v4.css'), postProcessV4, 'utf-8'); + writeFileSync(join(this.facetRoot, 'post-process.entry.ts'), "import './post-process.css';\n", 'utf-8'); + writeFileSync(join(this.facetRoot, 'post-process-v4.entry.ts'), "import './post-process-v4.css';\n", 'utf-8'); + this.logger.debug(`Generated Facet CSS entry with ${imports.length} consumer stylesheet import(s)`); + } - this.logger.debug('Copying default styles.css'); - try { - const styles = this.readFacetAsset('src/styles.css', stylesCss, 'styles.css'); - writeFileSync(join(this.facetSrc, 'styles.css'), styles, 'utf-8'); - this.logger.debug('Copied styles.css'); - } catch (error) { - this.logger.warn(`Failed to copy styles.css: ${error}`); + /** + * Digest of the generated build configuration that shapes SSR output — + * folded into the build-cache key so config changes at an unchanged facet + * version invalidate cached bundles. + */ + generatedConfigDigest(): string { + const hash = createHash('sha256'); + const generated = [ + 'vite.config.ts', 'postcss.config.js', 'tailwind.config.js', + 'tailwind.postprocess.config.js', 'post-process.css', 'post-process-v4.css', + ]; + for (const name of generated) { + try { + hash.update(name); + hash.update('\0'); + hash.update(readFileSync(join(this.facetRoot, name))); + hash.update('\0'); + } catch { /* variant-specific file not generated */ } } + return hash.digest('hex'); } - /** * Get the path to the .facet/ directory */ diff --git a/cli/src/builders/remark-config.test.ts b/cli/src/builders/remark-config.test.ts index 14ee1cf..04ef9e9 100644 --- a/cli/src/builders/remark-config.test.ts +++ b/cli/src/builders/remark-config.test.ts @@ -3,6 +3,7 @@ import { extractFrontmatter, remarkConfigFromFrontmatter, generatePluginCodegen, + rehypePluginsArray, remarkPluginsArray, hasPlugins, } from './remark-config.js'; @@ -65,8 +66,21 @@ describe('generatePluginCodegen', () => { describe('remarkPluginsArray', () => { it('keeps the always-on defaults first and appends user items', () => { - expect(remarkPluginsArray([])).toBe('[remarkFrontmatter, remarkGfm]'); - expect(remarkPluginsArray(['_remarkPlugin0'])).toBe('[remarkFrontmatter, remarkGfm, _remarkPlugin0]'); + expect(remarkPluginsArray([])).toBe("[remarkFrontmatter, remarkGfm, [remarkAlert, { tagName: 'blockquote' }]]"); + expect(remarkPluginsArray(['_remarkPlugin0'])).toBe( + "[remarkFrontmatter, remarkGfm, [remarkAlert, { tagName: 'blockquote' }], _remarkPlugin0]", + ); + }); +}); + +describe('rehypePluginsArray', () => { + it('keeps raw HTML before user items', () => { + expect(rehypePluginsArray([])).toBe( + "[[rehypeRaw, { passThrough: ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'] }]]", + ); + expect(rehypePluginsArray(['_rehypePlugin0'])).toBe( + "[[rehypeRaw, { passThrough: ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'] }], _rehypePlugin0]", + ); }); }); diff --git a/cli/src/builders/remark-config.ts b/cli/src/builders/remark-config.ts index edeafc8..5d864a3 100644 --- a/cli/src/builders/remark-config.ts +++ b/cli/src/builders/remark-config.ts @@ -91,5 +91,18 @@ export function generatePluginCodegen(config: RemarkConfig, projectRoot: string) /** The mdx() `remarkPlugins` array source: always-on defaults plus user items. */ export function remarkPluginsArray(userItems: string[]): string { - return `[remarkFrontmatter, remarkGfm${userItems.length ? ', ' + userItems.join(', ') : ''}]`; + return `[remarkFrontmatter, remarkGfm, [remarkAlert, { tagName: 'blockquote' }]${userItems.length ? ', ' + userItems.join(', ') : ''}]`; +} + +/** The mdx() `rehypePlugins` array source: always-on defaults plus user items. */ +export function rehypePluginsArray(userItems: string[]): string { + const mdxNodeTypes = [ + 'mdxFlowExpression', + 'mdxJsxFlowElement', + 'mdxJsxTextElement', + 'mdxTextExpression', + 'mdxjsEsm', + ]; + const passThrough = mdxNodeTypes.map((type) => `'${type}'`).join(', '); + return `[[rehypeRaw, { passThrough: [${passThrough}] }]${userItems.length ? ', ' + userItems.join(', ') : ''}]`; } diff --git a/cli/src/bundler/build-cache.test.ts b/cli/src/bundler/build-cache.test.ts index 244085f..c9137fa 100644 --- a/cli/src/bundler/build-cache.test.ts +++ b/cli/src/bundler/build-cache.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { computeTemplateBuildKey } from './build-cache.js'; @@ -22,37 +22,128 @@ afterEach(() => { describe('computeTemplateBuildKey', () => { it('is stable for unchanged sources', () => { const root = project(); - expect(computeTemplateBuildKey(root, '1')).toBe(computeTemplateBuildKey(root, '1')); + const options = { consumerRoot: root, facetVersion: '1' }; + expect(computeTemplateBuildKey(options)).toBe(computeTemplateBuildKey(options)); }); it('changes with source or Facet version', () => { const root = project(); - const initial = computeTemplateBuildKey(root, '1'); + const initial = computeTemplateBuildKey({ consumerRoot: root, facetVersion: '1' }); writeFileSync(join(root, 'src', 'Template.tsx'), 'export default () =>
'); - expect(computeTemplateBuildKey(root, '1')).not.toBe(initial); - expect(computeTemplateBuildKey(root, '2')).not.toBe(computeTemplateBuildKey(root, '1')); + expect(computeTemplateBuildKey({ consumerRoot: root, facetVersion: '1' })).not.toBe(initial); + expect(computeTemplateBuildKey({ consumerRoot: root, facetVersion: '2' })) + .not.toBe(computeTemplateBuildKey({ consumerRoot: root, facetVersion: '1' })); }); it('distinguishes entry files and content-addressed fragments', () => { const root = project(); writeFileSync(join(root, 'src', 'Other.tsx'), 'export default () =>