Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions apps/www/app/_utils/extract-stories.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ type StoryEntry = {
file: string;
};

type StoriesCacheEntry = {
signature: string;
stories: StoryEntry[];
};

/**
* Extracts exported functions from a given source code string. It looks for both named and default exports of functions.
*
Expand Down Expand Up @@ -51,10 +56,14 @@ export const extractExportedFunctions = (
};

// Extract exported story functions from *.stories.tsx and *.dodont.tsx
const storiesCache = new Map<string, StoriesCacheEntry>();

export const extractStories = (
componentPath: string,
dodont?: boolean,
): StoryEntry[] => {
const cacheKey = `${componentPath}:${dodont ? 'dodont' : 'stories'}`;

Comment thread
Barsnes marked this conversation as resolved.
try {
if (!existsSync(componentPath)) return [];

Expand All @@ -67,7 +76,8 @@ export const extractStories = (

if (stats.isFile()) {
// If it's a file, check if it matches the variant
files = [basename(componentPath)];
const file = basename(componentPath);
files = file.endsWith(variant) ? [file] : [];
baseDir = dirname(componentPath);
} else {
// If it's a directory, filter for matching files
Expand All @@ -77,12 +87,25 @@ export const extractStories = (

if (files.length === 0) return [];

return files.flatMap((file) => {
const signature = files
.map((file) => {
const full = join(baseDir, file);
const fileStats = statSync(full);
return `${file}:${fileStats.mtimeMs}:${fileStats.size}`;
})
.join('|');

const cached = storiesCache.get(cacheKey);
if (cached && cached.signature === signature) return cached.stories;

const result = files.flatMap((file) => {
const full = join(baseDir, file);
const src = readFileSync(full, 'utf-8');
const fns = extractExportedFunctions(src);
return fns.map((f) => ({ ...f, file }));
});
storiesCache.set(cacheKey, { signature, stories: result });
return result;
} catch (error) {
console.error('Error extracting stories:', error);
return [];
Expand Down
18 changes: 17 additions & 1 deletion apps/www/app/_utils/generate-from-mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import remarkGfm from 'remark-gfm';
import type { TableOfContentsItem, VFile } from './extract-toc';
import { extractToc } from './extract-toc';

// Cache MDX compilation results by source content to avoid recompiling identical files
const mdxCache = new Map<
string,
{
code: string;
// biome-ignore lint/suspicious/noExplicitAny: this is how frontmatter is typed in mdx-bundler
frontmatter: { [key: string]: any };
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use unknown here instead of any?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how it's typed in frontmatter, but I guess we could do unkown - should make no difference

toc: TableOfContentsItem[];
}
>();

export const generateFromMdx = async (
fileContent: string,
): Promise<{
Expand All @@ -14,6 +25,9 @@ export const generateFromMdx = async (
frontmatter: { [key: string]: any };
toc: TableOfContentsItem[];
}> => {
const cached = mdxCache.get(fileContent);
if (cached) return cached;

let tocData: TableOfContentsItem[] = [];

const result = await bundleMDX({
Expand All @@ -34,8 +48,10 @@ export const generateFromMdx = async (
},
});

return {
const output = {
...result,
toc: tocData,
};
mdxCache.set(fileContent, output);
return output;
};
91 changes: 69 additions & 22 deletions apps/www/app/_utils/get-react-props.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,66 @@ import {
} from 'react-docgen-typescript';

const require = createRequire(import.meta.url);
const shouldUseCache = process.env.NODE_ENV === 'production';

// Cache the parser as a singleton — withCustomConfig creates a TypeScript program
// which is very expensive. Reusing it across all component pages saves minutes of build time.
let cachedParser: ReturnType<typeof withCustomConfig> | undefined;

const getParser = () => {
return withCustomConfig(
require.resolve(
path.join(process.cwd(), '../../packages/react/tsconfig.lib.json'),
),
{
savePropValueAsString: true,
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
shouldExtractValuesFromUnion: true,
propFilter: (prop: PropItem) => {
const defaultLogicFromStorybook = prop.parent
? !/node_modules/.test(prop.parent.fileName)
: true;
return (
defaultLogicFromStorybook &&
prop.name !== 'popovertarget' &&
prop.name !== 'data-color' &&
prop.name !== 'data-color-scheme' &&
prop.name !== 'data-size'
);
if (!shouldUseCache) {
return withCustomConfig(
require.resolve(
path.join(process.cwd(), '../../packages/react/tsconfig.lib.json'),
),
{
savePropValueAsString: true,
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
shouldExtractValuesFromUnion: true,
propFilter: (prop: PropItem) => {
const defaultLogicFromStorybook = prop.parent
? !/node_modules/.test(prop.parent.fileName)
: true;
return (
defaultLogicFromStorybook &&
prop.name !== 'popovertarget' &&
prop.name !== 'data-color' &&
prop.name !== 'data-color-scheme' &&
prop.name !== 'data-size'
);
},
},
},
);
);
}

if (!cachedParser) {
cachedParser = withCustomConfig(
require.resolve(
path.join(process.cwd(), '../../packages/react/tsconfig.lib.json'),
),
{
savePropValueAsString: true,
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
shouldExtractValuesFromUnion: true,
propFilter: (prop: PropItem) => {
const defaultLogicFromStorybook = prop.parent
? !/node_modules/.test(prop.parent.fileName)
: true;
return (
defaultLogicFromStorybook &&
prop.name !== 'popovertarget' &&
prop.name !== 'data-color' &&
prop.name !== 'data-color-scheme' &&
prop.name !== 'data-size'
);
},
},
);
}

return cachedParser;
};

// Get the absolute path to the component directory using require.resolve
Expand All @@ -59,7 +94,16 @@ const getReactDir = (component: string) => {
return '';
};

// Cache component docs by component name — each component has up to 6 prerender
// pages (3 page types × 2 languages) that all need the same docs
const componentDocsCache = new Map<string, ComponentDoc[]>();

export const getComponentDocs = (component: string): ComponentDoc[] => {
if (shouldUseCache) {
const cached = componentDocsCache.get(component);
if (cached) return cached;
}

Comment thread
Barsnes marked this conversation as resolved.
const reactDir = getReactDir(component);
try {
if (!reactDir || !existsSync(reactDir)) {
Expand Down Expand Up @@ -91,6 +135,9 @@ export const getComponentDocs = (component: string): ComponentDoc[] => {
allDocs.push(...docs);
}

if (shouldUseCache) {
componentDocsCache.set(component, allDocs);
}
return allDocs;
} catch (error) {
console.error('Error parsing component docs:', error);
Expand Down
76 changes: 48 additions & 28 deletions apps/www/app/routes/components/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,51 @@ import classes from './component.module.css';

const require = createRequire(import.meta.url);

// Cache CSS resolution and parsing per cssFile — shared across all pages for the same component
const cssCache = new Map<
string,
{
cssSource?: string;
cssVars: Record<string, string>;
cssAttrs: Record<string, string>;
}
>();
const warnedCssFiles = new Set<string>();

const getComponentCss = (cssFile: string) => {
const cached = cssCache.get(cssFile);
if (cached) return cached;

Comment thread
Barsnes marked this conversation as resolved.
const emptyResult = {
cssSource: undefined,
cssVars: {},
cssAttrs: {},
};

try {
const cssPath = require.resolve(`@digdir/designsystemet-css/${cssFile}`);
const cssSource = readFileSync(cssPath, 'utf-8');
const result = {
cssSource,
cssVars: getCssVariables(cssSource),
cssAttrs: getAttributes(cssSource),
};

cssCache.set(cssFile, result);
return result;
} catch (error) {
if (!warnedCssFiles.has(cssFile)) {
warnedCssFiles.add(cssFile);
console.warn(
`Failed to resolve or read CSS file "@digdir/designsystemet-css/${cssFile}".`,
error,
);
}

return emptyResult;
}
};

export { ErrorBoundary } from '~/root';

export const loader = async ({ params, request }: Route.LoaderArgs) => {
Expand Down Expand Up @@ -94,34 +139,9 @@ export const loader = async ({ params, request }: Route.LoaderArgs) => {
jsonMetadata[lang].subtitle,
);

// Resolve raw CSS for this component from @digdir/designsystemet-css

let cssPath: string | undefined;

try {
cssPath = require.resolve(
`@digdir/designsystemet-css/${jsonMetadata.cssFile}`,
);
} catch {
console.warn(
`Could not resolve CSS file for component ${component}: ${jsonMetadata.cssFile}`,
);
}

let cssSource: string | undefined;
let cssVars: {
[key: string]: string;
} = {};
let cssAttrs: {
[key: string]: string;
} = {};
if (cssPath) {
try {
cssSource = readFileSync(cssPath, 'utf-8');
cssVars = getCssVariables(cssSource);
cssAttrs = getAttributes(cssSource);
} catch {}
}
const { cssSource, cssVars, cssAttrs } = getComponentCss(
jsonMetadata.cssFile,
);

return {
component,
Expand Down
36 changes: 24 additions & 12 deletions apps/www/app/routes/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
WrenchIcon,
} from '@navikt/aksel-icons';
import cl from 'clsx/lite';
import { bundleMDX } from 'mdx-bundler';
import { useTranslation } from 'react-i18next';
import { BlogCard } from '~/_components/blog-card/blog-card';
import { ImageBanner } from '~/_components/image-banner/image-banner';
Expand Down Expand Up @@ -57,27 +56,40 @@ export const loader = async ({ params: { lang } }: Route.LoaderArgs) => {
};
}[] = [];

/* Map over files with mdx parser to get title */
/* Map over files to extract frontmatter without full MDX compilation */
for (const file of mdxFiles) {
const fileContent = getFileFromContentDir(
join('blog', lang, file.relativePath),
);
const result = await bundleMDX({
source: fileContent,
});

const title =
result.frontmatter.title || file.relativePath.replace('.mdx', '');
// Parse frontmatter directly — much faster than bundleMDX.
const fmMatch = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter: Record<string, string> = {};
if (fmMatch) {
for (const line of fmMatch[1].split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const val = line
.slice(colonIdx + 1)
.trim()
.replace(/^['"]|['"]$/g, '');
frontmatter[key] = val;
}
}
}

const title = frontmatter.title || file.relativePath.replace('.mdx', '');
const url = file.relativePath.replace('.mdx', '');
posts.push({
title,
author: result.frontmatter.author || 'Unknown Author',
description: result.frontmatter.description || 'No description available',
author: frontmatter.author || 'Unknown Author',
description: frontmatter.description || 'No description available',
url,
date: result.frontmatter.date || '2000-01-01',
date: frontmatter.date || '2000-01-01',
image: {
src: result.frontmatter.imageSrc || '',
alt: result.frontmatter.imageAlt || '',
src: frontmatter.imageSrc || '',
alt: frontmatter.imageAlt || '',
},
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/www/react-router.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const config: Config = {
buildDirectory: 'dist',
prerender: {
paths: generatePrerenderPaths(),
unstable_concurrency: 10,
unstable_concurrency: 25,
},
presets: [],
future: {
Expand Down
Loading