astro:cache

This commit is contained in:
babayaga 2025-12-31 12:21:55 +01:00
parent 2ec586123d
commit 73a12c95e3
90 changed files with 14164 additions and 1730 deletions

2
packages/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

10
packages/cache/env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="astro/client" />
/// <reference types="murmurhash-native" />
declare module 'astro:assets' {
export * from 'astro/assets';
}
declare module 'astro:content' {
export * from 'astro/content/runtime';
}

8453
packages/cache/package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

68
packages/cache/package.json vendored Normal file
View File

@ -0,0 +1,68 @@
{
"name": "@polymech/astro-cache",
"version": "1.0.0",
"description": "Expanding™ the™ bits™ since™ 2024-12-12™",
"contributors": [
"Luiz Ferraz (https://github.com/Fryuni)",
"Louis Escher (https://github.com/louisescher)",
"Reuben Tier (https://github.com/theotterlord)"
],
"license": "MIT",
"keywords": [
"astro-integration",
"astro-component",
"withastro",
"astro"
],
"homepage": "https://github.com/astro-expansion/domain-expansion",
"publishConfig": {
"access": "public"
},
"sideEffects": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc --watch",
"build": "tsc",
"test": "vitest run --coverage",
"test:dev": "vitest",
"test:debug": "vitest run --inspect --no-file-parallelism --testTimeout 1000000"
},
"type": "module",
"peerDependencies": {
"astro": "^5.0.0"
},
"dependencies": {
"@inox-tools/utils": "^0.3.0",
"chalk": "^5.4.1",
"debug": "^4.4.0",
"estree-walker": "^3.0.3",
"hash-sum": "^2.0.0",
"human-format": "^1.2.1",
"magic-string": "^0.30.15",
"murmurhash-native": "^3.5.0",
"pathe": "^1.1.2"
},
"devDependencies": {
"@inox-tools/astro-tests": "^0.2.1",
"@types/debug": "^4.1.12",
"@types/hash-sum": "^1.0.2",
"@types/node": "^22.10.2",
"@vitest/coverage-v8": "2.1.8",
"@vitest/ui": "^2.1.8",
"astro": "^5.0.0",
"astro-integration-kit": "^0.17.0",
"jest-extended": "^4.0.2",
"rollup": "^4.29.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vitest": "^2.1.8"
}
}

139
packages/cache/src/cache.ts vendored Normal file
View File

@ -0,0 +1,139 @@
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
import { rootDebug } from './debug.js';
import { type MaybePromise, type Thunk } from './utils.js';
import { type PersistedMetadata, RenderFileStore } from './renderFileStore.js';
import { inMemoryCacheHit, inMemoryCacheMiss } from './metrics.js';
import { MemoryCache } from './inMemoryLRU.js';
import { FactoryValueClone } from './factoryValueClone.ts';
const debug = rootDebug.extend('cache');
type ValueThunk = Thunk<AstroFactoryReturnValue>;
export class Cache {
private readonly valueCache = new MemoryCache<Thunk<AstroFactoryReturnValue> | null>();
private readonly metadataCache = new MemoryCache<PersistedMetadata | null>();
private readonly persisted: RenderFileStore;
public constructor(cacheDir: string) {
this.persisted = new RenderFileStore(cacheDir);
}
public initialize(): Promise<void> {
return this.persisted.initialize();
}
public async flush(): Promise<void> {
await this.persisted.flush();
this.valueCache.clear();
this.metadataCache.clear();
const self = this as any;
delete self.valueCache;
delete self.metadataCache;
}
public saveRenderValue({
key,
factoryValue,
...options
}: {
key: string;
factoryValue: AstroFactoryReturnValue;
persist: boolean;
skipInMemory: boolean;
}): Promise<ValueThunk> {
const promise = options.persist
? this.persisted.saveRenderValue(key, factoryValue)
: FactoryValueClone.makeResultClone(factoryValue);
if (!options.skipInMemory) this.valueCache.storeLoading(key, promise);
return promise;
}
public async getRenderValue({
key,
loadFresh,
...options
}: {
key: string;
loadFresh: Thunk<MaybePromise<AstroFactoryReturnValue>>;
persist: boolean;
force: boolean;
skipInMemory: boolean;
}): Promise<{ cached: boolean; value: ValueThunk }> {
const value = await this.getStoredRenderValue(key, options.force, options.skipInMemory);
if (value) return { cached: true, value };
return {
cached: false,
value: await this.saveRenderValue({
...options,
key,
factoryValue: await loadFresh(),
}),
};
}
public saveMetadata({
key,
metadata,
persist,
skipInMemory,
}: {
key: string;
metadata: PersistedMetadata;
persist: boolean;
skipInMemory: boolean;
}): void {
if (!skipInMemory) this.metadataCache.storeSync(key, metadata);
if (persist) this.persisted.saveMetadata(key, metadata);
}
public async getMetadata({
key,
skipInMemory,
}: {
key: string;
skipInMemory: boolean;
}): Promise<PersistedMetadata | null> {
const fromMemory = this.metadataCache.get(key);
if (fromMemory) {
debug(`Retrieve metadata for "${key}" from memory`);
inMemoryCacheHit();
return fromMemory;
}
inMemoryCacheMiss();
const newPromise = this.persisted.loadMetadata(key);
if (!skipInMemory) this.metadataCache.storeLoading(key, newPromise);
return newPromise;
}
private getStoredRenderValue(
key: string,
force: boolean,
skipInMemory: boolean
): MaybePromise<ValueThunk | null> {
const fromMemory = this.valueCache.get(key);
if (fromMemory) {
debug(`Retrieve renderer for "${key}" from memory`);
inMemoryCacheHit();
return fromMemory;
}
inMemoryCacheMiss();
if (force) return null;
const newPromise = this.persisted.loadRenderer(key);
if (!skipInMemory) this.valueCache.storeLoading(key, newPromise);
return newPromise;
}
}

150
packages/cache/src/contextTracking.ts vendored Normal file
View File

@ -0,0 +1,150 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { runtime } from './utils.js';
import type { getImage } from 'astro:assets';
import { rootDebug } from './debug.js';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import type { renderEntry } from 'astro/content/runtime';
import type { UnresolvedImageTransform } from 'astro';
import { getSystemErrorName, types } from 'node:util';
import { createResolver } from 'astro-integration-kit';
import type { Cache } from './cache.js';
export type ContextTracking = {
assetServiceCalls: Array<{
options: UnresolvedImageTransform;
resultingAttributes: Record<string, any>;
}>;
renderEntryCalls: Array<{
id: string;
filePath: string;
hash: string;
}>;
nestedComponents: Record<string, string>;
doNotCache: boolean;
renderingEntry: boolean;
};
const debug = rootDebug.extend('context-tracking');
const contextTracking = new AsyncLocalStorage<ContextTracking>();
let cachingOptions: {
cache: Cache;
root: string;
routeEntrypoints: string[];
componentHashes: Map<string, string>;
cacheComponents: false | 'in-memory' | 'persistent';
cachePages: boolean;
componentsHaveSharedState: boolean;
resolver: ReturnType<typeof createResolver>['resolve'];
};
export function setCachingOptions(options: Omit<typeof cachingOptions, 'resolver'>) {
cachingOptions = {
...options,
resolver: createResolver(options.root).resolve,
};
}
export function getCachingOptions(): typeof cachingOptions {
return cachingOptions;
}
export function makeContextTracking(): {
runIn: <T>(fn: () => T) => T;
collect: () => ContextTracking;
} {
debug('Initializing asset collector');
const parent = contextTracking.getStore();
const context: ContextTracking = {
assetServiceCalls: [],
renderEntryCalls: [],
nestedComponents: {},
doNotCache: false,
renderingEntry: false,
};
return {
runIn: <T>(fn: () => T): T => {
return contextTracking.run(context, fn);
},
collect: () => {
debug('Retrieving collected context', {
assetCalls: context.assetServiceCalls.length,
ccRenderCalls: context.renderEntryCalls.length,
nestedComponents: Object.keys(context.nestedComponents),
});
if (parent) {
parent.assetServiceCalls.push(...context.assetServiceCalls);
parent.renderEntryCalls.push(...context.renderEntryCalls);
Object.assign(parent.nestedComponents, context.nestedComponents);
parent.doNotCache ||= context.doNotCache;
}
return context;
},
};
}
export function getCurrentContext(): ContextTracking | undefined {
return contextTracking.getStore();
}
const assetTrackingSym = Symbol.for('@domain-expansion:astro-asset-tracking');
(globalThis as any)[assetTrackingSym] = (original: typeof getImage): typeof getImage => {
debug('Assigning original getImage, skipping wrapper');
debugger
runtime.getImage = original;
return original;
};
export async function computeEntryHash(filePath: string): Promise<string> {
try {
return createHash('sha1')
.update(await fs.promises.readFile(cachingOptions.resolver(filePath)))
.digest()
.toString('hex');
} catch (err) {
if (
types.isNativeError(err) &&
'errno' in err &&
typeof err.errno === 'number' &&
getSystemErrorName(err.errno) === 'ENOENT'
) {
// Placeholder hash for entries attempting to render a missing file
return '__NO_FILE__';
}
throw err;
}
}
const ccRenderTrackingSym = Symbol.for('@domain-expansion:astro-cc-render-tracking');
(globalThis as any)[ccRenderTrackingSym] = (original: typeof renderEntry): typeof renderEntry => {
debug('Wrapping renderEntry');
return (runtime.renderEntry = async (entry) => {
const context = contextTracking.getStore();
if (!context) return original(entry);
if (!('id' in entry && entry.filePath)) {
context.doNotCache = true;
return original(entry);
}
const hash = await computeEntryHash(entry.filePath);
const val: ContextTracking['renderEntryCalls'][number] = {
id: entry.id,
filePath: entry.filePath,
hash,
};
debug('Collected renderEntry call', val);
context.renderEntryCalls.push(val);
context.renderingEntry = true;
const result = await original(entry);
context.renderingEntry = false;
return result;
});
};

3
packages/cache/src/debug.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import debugC from 'debug';
export const rootDebug = debugC('domain-expansion');

87
packages/cache/src/factoryValueClone.ts vendored Normal file
View File

@ -0,0 +1,87 @@
import type { RenderTemplateResult } from 'astro/runtime/server/render/astro/render-template.js';
import { runtime, type Thunk } from './utils.ts';
import type {
RenderDestination,
RenderDestinationChunk,
} from 'astro/runtime/server/render/common.js';
import type { HeadAndContent, ThinHead } from 'astro/runtime/server/render/astro/head-and-content.js';
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
export namespace FactoryValueClone {
export function makeResultClone(
value: AstroFactoryReturnValue
): Promise<Thunk<AstroFactoryReturnValue>> {
if (value instanceof Response) {
return makeResponseClone(value);
}
if (runtime.isHeadAndContent(value)) {
return makeHeadAndContentClone(value);
}
if (runtime.isRenderTemplateResult(value)) {
return makeRenderTemplateClone(value);
}
// ThinHead - no content to clone
return makeThinHeadClone(value as ThinHead);
}
export async function makeThinHeadClone(value: ThinHead): Promise<Thunk<ThinHead>> {
return () => value;
}
export async function makeResponseClone(value: Response): Promise<Thunk<Response>> {
const body = await value.arrayBuffer();
return () => new Response(body, value);
}
export async function makeRenderTemplateClone(
value: RenderTemplateResult
): Promise<Thunk<RenderTemplateResult>> {
const chunks = await renderTemplateToChunks(value);
return () => renderTemplateFromChunks(chunks);
}
export async function makeHeadAndContentClone(
value: HeadAndContent
): Promise<Thunk<HeadAndContent>> {
const chunks = await renderTemplateToChunks(value.content);
return () => runtime.createHeadAndContent(value.head, renderTemplateFromChunks(chunks));
}
export function renderTemplateFromChunks(chunks: RenderDestinationChunk[]): RenderTemplateResult {
const template = runtime.renderTemplate(Object.assign([], { raw: [] }));
return Object.assign(template, {
render: (destination: RenderDestination) => {
return new Promise<void>((resolve) => {
setImmediate(() => {
for (const chunk of chunks) {
destination.write(chunk);
}
resolve();
});
});
},
});
}
export async function renderTemplateToChunks(
value: RenderTemplateResult
): Promise<RenderDestinationChunk[]> {
const chunks: RenderDestinationChunk[] = [];
const cachedDestination: RenderDestination = {
write(chunk) {
// Drop empty chunks
if (chunk) chunks.push(chunk);
},
};
await value.render(cachedDestination);
return chunks;
}
}

92
packages/cache/src/inMemoryLRU.ts vendored Normal file
View File

@ -0,0 +1,92 @@
import { types } from 'node:util';
import { rootDebug } from './debug.js';
import { Either, type MaybePromise } from './utils.js';
// Arbitrary limit for now
const CACHE_LIMIT = 4096;
const debug = rootDebug.extend('lru-cache');
export class MemoryCache<T> {
readonly #cacheLimit: number;
readonly #cache = new Map<string, Either<T, Promise<T>>>();
public constructor(cacheLimit: number = CACHE_LIMIT) {
this.#cacheLimit = cacheLimit;
}
public async load(key: string, loader: () => MaybePromise<T>): Promise<T> {
const cached = await this.get(key);
if (cached) return cached;
const fresh = loader();
if (types.isPromise(fresh)) {
return this.storeLoading(key, fresh);
}
this.storeSync(key, fresh);
return fresh;
}
public async getAll(): Promise<Record<string, T>> {
return Object.fromEntries(
await Promise.all(
Array.from(this.#cache.entries()).map(([k, v]) =>
Either.isLeft(v) ? [k, v.value] : v.value.then((value) => [k, value])
)
)
);
}
public get(key: string): MaybePromise<T> | null {
const cached = this.#cache.get(key);
if (!cached) return null;
this.#cache.delete(key);
this.#cache.set(key, cached);
while (this.#cache.size > this.#cacheLimit) {
const { value } = this.#cache.keys().next();
this.#cache.delete(value!);
}
if (Either.isLeft(cached)) return cached.value;
return cached.value;
}
public storeSync(key: string, value: T): void {
this.#cache.set(key, Either.left(value));
}
public storeLoading(key: string, promise: Promise<T>): Promise<T> {
// Use a 3-stage cache with a loading stage holding the promises
// to avoid duplicate reading from not caching the promise
// and memory leaks to only caching the promises.
const stored = Either.right(promise);
this.#cache.set(key, stored);
return promise
.then((result) => {
const cached = this.#cache.get(key);
if (!Object.is(cached, stored)) return cached!.value;
debug(`Storing cached render for "${key}"`);
this.#cache.set(key, Either.left(result));
return result;
})
.finally(() => {
const cached = this.#cache.get(key);
if (!Object.is(cached, stored)) return;
debug(`Clearing loading state for "${key}"`);
this.#cache.delete(key);
});
}
public clear(): void {
this.#cache.clear();
}
}

3
packages/cache/src/index.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { integration } from './integration.js';
export default integration;

142
packages/cache/src/integration.ts vendored Normal file
View File

@ -0,0 +1,142 @@
import { addIntegration, defineIntegration } from 'astro-integration-kit';
import { interceptorPlugin } from './interceptor.js';
import { clearMetrics, collectMetrics } from './metrics.js';
import chalk from 'chalk';
import humanFormat from 'human-format';
import { z } from 'astro/zod';
function getDefaultCacheComponents(): false | 'in-memory' | 'persistent' {
const env = process.env.DOMAIN_EXPANSION_CACHE_COMPONENT;
switch (env) {
case 'false':
return false;
case 'in-memory':
return 'in-memory';
case 'persistent':
return 'persistent';
case '':
case undefined:
return false;
default:
console.warn(
chalk.bold.redBright(`Invalid environment variable value for component cache: ${env}`)
);
console.warn(chalk.italic.yellow('Assuming "in-memory" as default.'));
return 'in-memory';
}
}
export const INTEGRATION_NAME = '@domain-expansion/astro';
export const integration = defineIntegration({
name: INTEGRATION_NAME,
optionsSchema: z
.object({
/**
* Whether non-page components should be cached.
*
* - `false` (default) means not caching at all
* - `in-memory` means deduplicating repeated uses of components
* without persisting them to disk
* - `persistent` means persisting all uses of components to disk
* just like pages. Changes to other segments of a page will use
* the cached result of all unchanged components
*
* Components receiving slots are never cached.
* If your component relies on state provided through Astro.locals
* or any other means (like Starlight), you should also enable
* `componentHasSharedState` to make sure the component is only
* reused when the shared state is not expected to change.
*/
cacheComponents: z
.enum(['in-memory', 'persistent'])
.or(z.literal(false))
.default(getDefaultCacheComponents()),
componentsHaveSharedState: z
.boolean()
.default(process.env.DOMAIN_EXPANSION_STATEFUL_COMPONENTS === 'true'),
cachePages: z
.boolean()
.default((process.env.DOMAIN_EXPANSION_CACHE_PAGES || 'true') === 'true'),
/**
* Cache prefix used to store independent cache data across multiple runs.
*
* @internal
*/
cachePrefix: z.string().optional().default(''),
})
.default({}),
setup({ options }) {
const routeEntrypoints: string[] = [];
let cleanup: undefined | (() => Promise<void>);
return {
hooks: {
'astro:routes:resolved': (params) => {
routeEntrypoints.length = 0;
routeEntrypoints.push(...params.routes.map((route) => route.entrypoint));
},
'astro:build:setup': ({ updateConfig, target }) => {
if (target === 'server') {
const interceptor = interceptorPlugin({
...options,
routeEntrypoints,
});
cleanup = interceptor.cleanup;
updateConfig({
plugins: [interceptor.plugin],
});
}
},
'astro:build:done': async () => {
await cleanup?.();
},
'astro:config:setup': (params) => {
if (params.command !== 'build') return;
clearMetrics();
addIntegration(params, {
ensureUnique: true,
integration: {
name: '@domain-expansion/astro:reporting',
hooks: {
'astro:build:done': ({ logger }) => {
if (!['debug', 'info'].includes(logger.options.level)) return;
const metrics = collectMetrics();
const fsCacheTotal = metrics['fs-cache-hit'] + metrics['fs-cache-miss'];
const fsHitRatio = (100 * metrics['fs-cache-hit']) / fsCacheTotal;
const inMemoryCacheTotal =
metrics['in-memory-cache-hit'] + metrics['in-memory-cache-miss'];
const inMemoryHitRatio =
(100 * metrics['in-memory-cache-hit']) / inMemoryCacheTotal;
// TODO: Add metrics for rollup time
console.log(`
${chalk.bold.cyan('[Domain Expansion report]')}
${chalk.bold.green('FS hit ratio:')} ${fsHitRatio.toFixed(2)}%
${chalk.bold.green('FS hit total:')} ${humanFormat(metrics['fs-cache-hit'])}
${chalk.bold.green('FS miss total:')} ${humanFormat(metrics['fs-cache-miss'])}
${chalk.bold.green('In-Memory hit ratio:')} ${inMemoryHitRatio.toFixed(2)}%
${chalk.bold.green('In-Memory hit total:')} ${humanFormat(metrics['in-memory-cache-hit'])}
${chalk.bold.green('In-Memory miss total:')} ${humanFormat(metrics['in-memory-cache-miss'])}
${chalk.bold.green('Stored data in FS:')} ${humanFormat.bytes(metrics['stored-compressed-size'])}
${chalk.bold.green('Loaded data from FS:')} ${humanFormat.bytes(metrics['loaded-compressed-size'])}
${chalk.bold.green('Stored data uncompressed:')} ${humanFormat.bytes(metrics['stored-data-size'])}
${chalk.bold.green('Loaded data uncompressed:')} ${humanFormat.bytes(metrics['loaded-data-size'])}
`);
},
},
},
});
},
},
};
},
});

286
packages/cache/src/interceptor.ts vendored Normal file
View File

@ -0,0 +1,286 @@
import type { Plugin } from 'vite';
import type { AstNode, TransformPluginContext } from 'rollup';
import { walk, type Node as ETreeNode } from 'estree-walker';
import { rootDebug } from './debug.js';
import { AstroError } from 'astro/errors';
import { setCachingOptions } from './contextTracking.js';
import { Cache } from './cache.js';
import { createResolver } from 'astro-integration-kit';
import MagicString, { type SourceMap } from 'magic-string';
import hash_sum from 'hash-sum';
import assert from 'node:assert';
import './renderCaching.js';
import { randomBytes } from 'node:crypto';
const debug = rootDebug.extend('interceptor-plugin');
const MODULE_ID = 'virtual:domain-expansion';
const RESOLVED_MODULE_ID = '\x00virtual:domain-expansion';
const EXCLUDED_MODULE_IDS: string[] = [RESOLVED_MODULE_ID, '\0astro:content', '\0astro:assets'];
type ParseNode = ETreeNode & AstNode;
export const interceptorPlugin = (options: {
cacheComponents: false | 'in-memory' | 'persistent';
cachePages: boolean;
componentsHaveSharedState: boolean;
routeEntrypoints: string[];
cachePrefix: string;
}): { plugin: Plugin; cleanup: () => Promise<void> } => {
const componentHashes = new Map<string, string>();
let cache: Cache;
const plugin: Plugin = {
name: '@domain-expansion/interceptor',
enforce: 'post',
async configResolved(config) {
const { resolve: resolver } = createResolver(config.root);
cache = new Cache(resolver(`node_modules/.domain-expansion/${options.cachePrefix}`));
await cache.initialize();
setCachingOptions({
...options,
cache,
root: config.root,
routeEntrypoints: options.routeEntrypoints.map((entrypoint) => resolver(entrypoint)),
componentHashes,
});
},
resolveId(id) {
if (id === MODULE_ID) return RESOLVED_MODULE_ID;
return null;
},
load(id, { ssr } = {}) {
if (id !== RESOLVED_MODULE_ID) return;
if (!ssr) throw new AstroError("Client domain can't be expanded.");
// Return unchanged functions when not in a shared context with the build pipeline
// AKA. During server rendering
const code = `
import { HTMLBytes, HTMLString } from "astro/runtime/server/index.js";
import { SlotString } from "astro/runtime/server/render/slot.js";
import { createHeadAndContent, isHeadAndContent } from "astro/runtime/server/render/astro/head-and-content.js";
import { isRenderTemplateResult, renderTemplate } from "astro/runtime/server/render/astro/render-template.js";
import { createRenderInstruction } from "astro/runtime/server/render/instruction.js";
Object.assign(globalThis[Symbol.for('@domain-expansion:astro-runtime-instances')] ?? {}, {
HTMLBytes,
HTMLString,
SlotString,
createHeadAndContent,
isHeadAndContent,
renderTemplate,
isRenderTemplateResult,
createRenderInstruction,
});
const compCacheSym = Symbol.for('@domain-expansion:astro-component-caching');
export const domainExpansionComponents = globalThis[compCacheSym] ?? ((fn) => fn);
const assetTrackingSym = Symbol.for('@domain-expansion:astro-asset-tracking');
export const domainExpansionAssets = globalThis[assetTrackingSym] ?? ((fn) => fn);
const ccRenderTrackingSym = Symbol.for('@domain-expansion:astro-cc-render-tracking');
export const domainExpansionRenderEntry = globalThis[ccRenderTrackingSym] ?? ((fn) => fn);
`;
if (process.env.TEST)
return code + `domainExpansionAssets(${JSON.stringify(randomBytes(8).toString('hex'))});`;
return code;
},
async transform(code, id, { ssr } = {}) {
if (!ssr) return;
const transformers: Transformer[] = [
createComponentTransformer,
getImageAssetTransformer,
renderCCEntryTransformer,
];
for (const transformer of transformers) {
const result = transformer(this, code, id);
if (result) return result;
}
return;
},
async generateBundle() {
for (const rootName of this.getModuleIds()) {
if (!rootName.endsWith('.astro')) continue;
const processedImports: string[] = [];
const hashParts: string[] = [];
const importQueue = [rootName];
while (importQueue.length) {
const modName = importQueue.pop()!;
const modInfo = this.getModuleInfo(modName)!;
if (modInfo.isExternal || !modInfo.code) continue;
processedImports.push(modName);
hashParts.push(modInfo.code);
importQueue.push(
...modInfo.importedIdResolutions
.map((resolution) => resolution.id)
.filter(
(importId) =>
!importId.endsWith('.astro') &&
!EXCLUDED_MODULE_IDS.includes(importId) &&
!processedImports.includes(importId)
)
);
}
componentHashes.set(rootName, hash_sum(hashParts));
}
},
};
return {
plugin,
cleanup: () => cache.flush(),
};
};
type Transformer = (
ctx: TransformPluginContext,
code: string,
id: string
) => TransformResult | null;
type TransformResult = {
code: string;
map: SourceMap;
};
const createComponentTransformer: Transformer = (ctx, code, id) => {
if (!code.includes('function createComponent(')) return null;
if (!/node_modules\/astro\/dist\/runtime\/[\w\/.-]+\.js/.test(id)) {
debug('"createComponent" declaration outside of expected module', { id });
return null;
}
const ms = new MagicString(code);
const ast = ctx.parse(code);
walk(ast, {
leave(estreeNode, parent) {
const node = estreeNode as ParseNode;
if (node.type !== 'FunctionDeclaration') return;
if (node.id.name !== 'createComponent') return;
if (parent?.type !== 'Program') {
throw new Error(
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
);
}
ms.prependLeft(
node.start,
[
`import {domainExpansionComponents as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};`,
'const createComponent = $$domainExpansion(',
].join('\n')
);
ms.appendRight(node.end, ');');
},
});
return {
code: ms.toString(),
map: ms.generateMap(),
};
};
const getImageAssetTransformer: Transformer = (ctx, code, id) => {
if (id !== '\0astro:assets') return null;
const ms = new MagicString(code);
const ast = ctx.parse(code);
const path: ParseNode[] = [];
walk(ast, {
enter(estreeNode) {
const node = estreeNode as ParseNode;
path.push(node);
if (
node.type !== 'VariableDeclarator' ||
node.id.type !== 'Identifier' ||
node.id.name !== 'getImage'
)
return;
const exportDeclaration = path.at(-3);
assert.ok(isParseNode(node.init));
assert.ok(
exportDeclaration?.type === 'ExportNamedDeclaration',
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
);
ms.prependLeft(
exportDeclaration.start,
`import {domainExpansionAssets as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};\n`
);
ms.prependLeft(node.init.start, '$$domainExpansion(');
ms.appendRight(node.init.end, ')');
},
leave(estreeNode) {
const lastNode = path.pop();
assert.ok(Object.is(lastNode, estreeNode), 'Stack tracking broke');
},
});
return {
code: ms.toString(),
map: ms.generateMap(),
};
};
const renderCCEntryTransformer: Transformer = (ctx, code, id) => {
if (!code.includes('function renderEntry(')) return null;
if (!/node_modules\/astro\/dist\/content\/[\w\/.-]+\.js/.test(id)) {
debug('"renderEntry" declaration outside of expected module', { id });
return null;
}
const ms = new MagicString(code);
const ast = ctx.parse(code);
walk(ast, {
enter(estreeNode, parent) {
const node = estreeNode as ParseNode;
if (node.type !== 'FunctionDeclaration') return;
if (node.id.name !== 'renderEntry') return;
if (parent?.type !== 'Program') {
throw new Error(
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
);
}
ms.prependLeft(
node.start,
[
`import {domainExpansionRenderEntry as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};\n`,
'const renderEntry = $$domainExpansion(',
].join('\n')
);
ms.appendRight(node.end, ');');
},
});
return {
code: ms.toString(),
map: ms.generateMap(),
};
};
function isParseNode(node?: ETreeNode | null): node is ParseNode {
return node != null && 'start' in node && typeof node.start === 'number';
}

37
packages/cache/src/metrics.ts vendored Normal file
View File

@ -0,0 +1,37 @@
type Metrics =
| 'in-memory-cache-hit'
| 'in-memory-cache-miss'
| 'fs-cache-hit'
| 'fs-cache-miss'
| 'loaded-data-size'
| 'stored-data-size'
| 'loaded-compressed-size'
| 'stored-compressed-size';
const metricState: Record<string, number> = {};
function makeTracker(name: Metrics): (n?: number) => void {
metricState[name] = 0;
return (n = 1) => {
metricState[name]! += n;
};
}
export const inMemoryCacheHit = makeTracker('in-memory-cache-hit');
export const inMemoryCacheMiss = makeTracker('in-memory-cache-miss');
export const fsCacheHit = makeTracker('fs-cache-hit');
export const fsCacheMiss = makeTracker('fs-cache-miss');
export const trackLoadedData = makeTracker('loaded-data-size');
export const trackStoredData = makeTracker('stored-data-size');
export const trackLoadedCompressedData = makeTracker('loaded-compressed-size');
export const trackStoredCompressedData = makeTracker('stored-compressed-size');
export type CollectedMetrics = Record<Metrics, number>;
export const collectMetrics = (): CollectedMetrics => ({ ...metricState }) as CollectedMetrics;
export const clearMetrics = (): void => {
for (const key in metricState) {
metricState[key] = 0;
}
};

283
packages/cache/src/renderCaching.ts vendored Normal file
View File

@ -0,0 +1,283 @@
import type * as Runtime from 'astro/compiler-runtime';
import hashSum from 'hash-sum';
import { rootDebug } from './debug.js';
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
import type { SSRMetadata, SSRResult } from 'astro';
import { runtime } from './utils.js';
import type { RenderDestination } from 'astro/runtime/server/render/common.js';
import type { PersistedMetadata } from './renderFileStore.js';
import { isDeepStrictEqual, types } from 'node:util';
import {
computeEntryHash,
getCachingOptions,
getCurrentContext,
makeContextTracking,
} from './contextTracking.js';
const debug = rootDebug.extend('render-caching');
const ASSET_SERVICE_CALLS = Symbol('@domain-expansion:astro-assets-service-calls');
interface ExtendedSSRResult extends SSRResult {
[ASSET_SERVICE_CALLS]: PersistedMetadata['assetServiceCalls'];
}
(globalThis as any)[Symbol.for('@domain-expansion:astro-component-caching')] = (
originalFn: typeof Runtime.createComponent
): typeof Runtime.createComponent => {
return function cachedCreateComponent(factoryOrOptions, moduleId, propagation) {
const options =
typeof factoryOrOptions === 'function'
? ({ factory: factoryOrOptions, moduleId, propagation } as Exclude<
typeof factoryOrOptions,
Function
>)
: factoryOrOptions;
const context = getCurrentContext();
let cacheScope = options.moduleId || '';
const { componentHashes } = getCachingOptions();
if (!options.moduleId || !componentHashes.has(options.moduleId)) {
if (!context) return originalFn(options);
delete options.moduleId;
if (!context.renderingEntry) {
context.doNotCache = true;
return originalFn(options);
}
const ccRenderCall = context.renderEntryCalls.at(-1)!;
cacheScope = `ccEntry:${ccRenderCall.id}:${ccRenderCall.hash}`;
} else {
const hash = componentHashes.get(options.moduleId)!;
cacheScope = hash;
}
return originalFn(
cacheFn(cacheScope, options.factory, options.moduleId),
options.moduleId,
options.propagation
);
};
function cacheFn(
cacheScope: string,
factory: AstroComponentFactory,
moduleId?: string
): AstroComponentFactory {
const { cache, routeEntrypoints, componentHashes, componentsHaveSharedState, ...cacheOptions } =
getCachingOptions();
const isEntrypoint = routeEntrypoints.includes(moduleId!);
const cacheParams: Record<'persist' | 'skipInMemory', boolean> = {
persist:
(isEntrypoint && cacheOptions.cachePages) ||
(!isEntrypoint && cacheOptions.cacheComponents === 'persistent'),
skipInMemory: isEntrypoint || cacheOptions.cacheComponents === false,
};
debug('Creating cached component', {
cacheScope,
moduleId,
isEntrypoint,
cacheParams,
});
return async (result: ExtendedSSRResult, props, slots) => {
const context = getCurrentContext();
if (context) {
if (moduleId) {
context.nestedComponents[moduleId] = componentHashes.get(moduleId)!;
}
}
if (!cacheParams.persist && cacheParams.skipInMemory) return factory(result, props, slots);
if (slots !== undefined && Object.keys(slots).length > 0) {
debug('Skip caching of component instance with children', { moduleId });
return factory(result, props, slots);
}
// TODO: Handle edge-cases involving Object.defineProperty
const resolvedProps = Object.fromEntries(
(
await Promise.all(
Object.entries(props).map(async ([key, value]) => [
key,
types.isProxy(value) ? undefined : await value,
])
)
).filter(([_key, value]) => !!value)
);
// We need to delete this because otherwise scopes from outside of a component can be globally
// restricted to the inside of a child component through a slot and to support that the component
// has to depend on its parent. Don't do that.
//
// This is required because this block in Astro doesn't return the `transformResult.scope`:
// https://github.com/withastro/astro/blob/799c8676dfba0d281faf2a3f2d9513518b57593b/packages/astro/src/vite-plugin-astro/index.ts?plain=1#L246-L257
// TODO: This might no longer be necessary, try removing it
const scopeProp = Object.keys(resolvedProps).find((prop) =>
prop.startsWith('data-astro-cid-')
);
if (scopeProp !== undefined) {
delete resolvedProps[scopeProp];
}
const url = new URL(result.request.url);
const hash = hashSum(
isEntrypoint || componentsHaveSharedState
? [moduleId, result.compressHTML, result.params, url.pathname, resolvedProps]
: [moduleId, result.compressHTML, resolvedProps]
);
const cacheKey = `${cacheScope}:${hash}`;
const { runIn: enterTrackingScope, collect: collectTracking } = makeContextTracking();
return enterTrackingScope(async () => {
const cachedMetadata = await getValidMetadata(cacheKey);
const cachedValue = await cache.getRenderValue({
key: cacheKey,
loadFresh: () => factory(result, props, slots),
force: !cachedMetadata,
...cacheParams,
});
const resultValue = cachedValue.value();
if (resultValue instanceof Response) return resultValue;
const templateResult = runtime.isRenderTemplateResult(resultValue)
? resultValue
: runtime.isHeadAndContent(resultValue)
? resultValue.content
: null;
if (!templateResult) return resultValue; // ThinHead case
const originalRender = templateResult.render;
if (cachedMetadata && cachedValue.cached) {
const { metadata } = cachedMetadata;
Object.assign(templateResult, {
render: async (destination: RenderDestination) => {
const newMetadata: SSRMetadata = {
...metadata,
extraHead: result._metadata.extraHead.concat(metadata.extraHead),
renderedScripts: new Set([
...result._metadata.renderedScripts.values(),
...metadata.renderedScripts.values(),
]),
hasDirectives: new Set([
...result._metadata.hasDirectives.values(),
...metadata.hasDirectives.values(),
]),
rendererSpecificHydrationScripts: new Set([
...result._metadata.rendererSpecificHydrationScripts.values(),
...metadata.rendererSpecificHydrationScripts.values(),
]),
propagators: result._metadata.propagators,
};
Object.assign(result._metadata, newMetadata);
return originalRender.call(templateResult, destination);
},
});
return resultValue;
}
const previousExtraHeadLength = result._metadata.extraHead.length;
const renderedScriptsDiff = delayedSetDifference(result._metadata.renderedScripts);
const hasDirectivedDiff = delayedSetDifference(result._metadata.hasDirectives);
const rendererSpecificHydrationScriptsDiff = delayedSetDifference(
result._metadata.rendererSpecificHydrationScripts
);
Object.assign(templateResult, {
render: (destination: RenderDestination) =>
enterTrackingScope(async () => {
// Renderer was not cached, so we need to cache the metadata as well
const context = collectTracking();
cache.saveMetadata({
key: cacheKey,
metadata: {
...context,
metadata: {
...result._metadata,
extraHead: result._metadata.extraHead.slice(previousExtraHeadLength),
renderedScripts: renderedScriptsDiff(result._metadata.renderedScripts),
hasDirectives: hasDirectivedDiff(result._metadata.hasDirectives),
rendererSpecificHydrationScripts: rendererSpecificHydrationScriptsDiff(
result._metadata.rendererSpecificHydrationScripts
),
},
},
...cacheParams,
});
return originalRender.call(templateResult, destination);
}),
});
return resultValue;
});
};
async function getValidMetadata(cacheKey: string): Promise<PersistedMetadata | null> {
const cachedMetadata = await cache.getMetadata({
key: cacheKey,
...cacheParams,
});
if (!cachedMetadata) return null;
for (const [component, hash] of Object.entries(cachedMetadata.nestedComponents)) {
const currentHash = componentHashes.get(component);
if (currentHash !== hash) return null;
}
for (const entry of cachedMetadata.renderEntryCalls) {
const currentHash = await computeEntryHash(entry.filePath);
if (currentHash !== entry.hash) return null;
}
for (const { options, resultingAttributes } of cachedMetadata.assetServiceCalls) {
try {
debug('Replaying getImage call', { options });
const result = await runtime.getImage(options);
if (!isDeepStrictEqual(result.attributes, resultingAttributes)) {
debug('Image call mismatch, bailing out of cache');
return null;
}
} catch (error) {
debug('Error replaying getImage call', { options, error });
return null;
}
}
return cachedMetadata;
}
}
};
function delayedSetDifference(previous: Set<string>): (next: Set<string>) => Set<string> {
const storedPrevious = new Set(previous);
return (next) => {
const newSet = new Set(next);
for (const k of storedPrevious.values()) {
newSet.delete(k);
}
return newSet;
};
}

502
packages/cache/src/renderFileStore.ts vendored Normal file
View File

@ -0,0 +1,502 @@
import { createResolver } from 'astro-integration-kit';
import type { RenderDestinationChunk } from 'astro/runtime/server/render/common.js';
import { rootDebug } from './debug.js';
import * as fs from 'node:fs';
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
import { Either, runtime, type Thunk } from './utils.js';
import type { SSRMetadata } from 'astro';
import type { RenderInstruction } from 'astro/runtime/server/render/instruction.js';
import * as zlib from 'node:zlib';
import { promisify } from 'node:util';
import {
fsCacheHit,
fsCacheMiss,
trackLoadedCompressedData,
trackLoadedData,
trackStoredCompressedData,
trackStoredData,
} from './metrics.js';
import type { ContextTracking } from './contextTracking.js';
import { MemoryCache } from './inMemoryLRU.js';
import murmurHash from 'murmurhash-native';
import { FactoryValueClone } from './factoryValueClone.ts';
const gzip = promisify(zlib.gzip),
gunzip = promisify(zlib.gunzip);
const NON_SERIALIZABLE_RENDER_INSTRUCTIONS = ['renderer-hydration-script'] satisfies Array<
RenderInstruction['type']
>;
type SerializableRenderInstruction = Exclude<
RenderInstruction,
{
type: (typeof NON_SERIALIZABLE_RENDER_INSTRUCTIONS)[number];
}
>;
type ChunkSerializationMap = {
primitive: { value: string | number | boolean };
htmlString: { value: string };
htmlBytes: { value: string };
slotString: {
value: string;
renderInstructions: Array<SerializableRenderInstruction> | undefined;
};
renderInstruction: {
instruction: SerializableRenderInstruction;
};
arrayBufferView: {
value: string;
};
response: {
body: string;
status: number;
statusText: string;
headers: Record<string, string>;
};
};
type SerializedChunk<K extends keyof ChunkSerializationMap = keyof ChunkSerializationMap> = {
[T in K]: ChunkSerializationMap[T] & { type: T };
}[K];
type ValueSerializationMap = {
headAndContent: {
head: string;
chunks: SerializedChunk[];
};
templateResult: {
chunks: SerializedChunk[];
};
response: ChunkSerializationMap['response'];
};
type SerializedValue<K extends keyof ValueSerializationMap = keyof ValueSerializationMap> = {
[T in K]: ValueSerializationMap[T] & { type: T };
}[K];
export type PersistedMetadata = Omit<ContextTracking, 'doNotCache' | 'renderingEntry'> & {
metadata: Omit<SSRMetadata, 'propagators'>;
};
export type SerializedMetadata = Omit<ContextTracking, 'doNotCache' | 'renderingEntry'> & {
metadata: {
hasHydrationScript: boolean;
rendererSpecificHydrationScripts: Array<string>;
renderedScripts: Array<string>;
hasDirectives: Array<string>;
hasRenderedHead: boolean;
hasRenderedServerIslandRuntime?: boolean;
headInTree: boolean;
extraHead: string[];
extraStyleHashes?: string[];
extraScriptHashes?: string[];
};
};
const debug = rootDebug.extend('file-store');
type ValueThunk = Thunk<AstroFactoryReturnValue>;
type DenormalizationResult<D, N> = {
denormalized?: D;
clone: Thunk<N>;
};
export class RenderFileStore {
private readonly gzippedCache = new MemoryCache<Buffer>(Number.POSITIVE_INFINITY);
private readonly resolver: ReturnType<typeof createResolver>['resolve'];
private readonly pending: Array<Promise<void>> = [];
private knownFiles!: string[];
public constructor(private readonly cacheDir: string) {
this.resolver = createResolver(this.cacheDir).resolve;
fs.mkdirSync(this.cacheDir, { recursive: true });
}
public async initialize(): Promise<void> {
if (fs.existsSync(this.cacheDir)) {
this.knownFiles = await fs.promises.readdir(this.cacheDir, {
recursive: true,
withFileTypes: false,
});
} else {
this.knownFiles = [];
}
await Promise.all(
this.knownFiles.map(async (hash) => {
try {
const stored = await fs.promises.readFile(this.resolver(hash));
this.gzippedCache.storeSync(hash, stored);
} catch {}
})
);
}
public async flush(): Promise<void> {
while (this.pending.length) {
await Promise.all(this.pending);
}
this.gzippedCache.clear();
this.knownFiles = [];
}
public async saveRenderValue(key: string, value: AstroFactoryReturnValue): Promise<ValueThunk> {
debug('Persisting renderer for ', key);
const { denormalized, clone } = await RenderFileStore.denormalizeValue(value);
if (denormalized) {
this.store(key + ':renderer', denormalized);
}
return clone;
}
public async loadRenderer(key: string): Promise<ValueThunk | null> {
try {
const serializedValue: SerializedValue | null = await this.load(key + ':renderer');
if (!serializedValue) {
debug('Renderer cache miss', key);
fsCacheMiss();
return null;
}
debug('Renderer cache hit', key);
fsCacheHit();
return RenderFileStore.normalizeValue(serializedValue);
} catch {
debug('Renderer cache miss', key);
fsCacheMiss();
return null;
}
}
public saveMetadata(key: string, metadata: PersistedMetadata): void {
debug('Persisting metadata for ', key);
const serialized: SerializedMetadata = {
...metadata,
metadata: {
...metadata.metadata,
hasDirectives: Array.from(metadata.metadata.hasDirectives),
renderedScripts: Array.from(metadata.metadata.renderedScripts),
rendererSpecificHydrationScripts: Array.from(
metadata.metadata.rendererSpecificHydrationScripts
),
},
};
this.store(key + ':metadata', serialized);
}
public async loadMetadata(key: string): Promise<PersistedMetadata | null> {
try {
const serializedValue: SerializedMetadata | null = await this.load(key + ':metadata');
if (!serializedValue) {
debug('Metadata cache miss', key);
fsCacheMiss();
return null;
}
debug('Metadata cache hit', key);
fsCacheHit();
return {
...serializedValue,
metadata: {
...serializedValue.metadata,
hasDirectives: new Set(serializedValue.metadata.hasDirectives),
renderedScripts: new Set(serializedValue.metadata.renderedScripts),
rendererSpecificHydrationScripts: new Set(
serializedValue.metadata.rendererSpecificHydrationScripts
),
hasRenderedServerIslandRuntime: serializedValue.metadata.hasRenderedServerIslandRuntime ?? false,
extraStyleHashes: serializedValue.metadata.extraStyleHashes ?? [],
extraScriptHashes: serializedValue.metadata.extraScriptHashes ?? [],
},
};
} catch {
debug('Metadata cache miss', key);
fsCacheMiss();
return null;
}
}
public store(cacheKey: string, data: any): void {
const promise = new Promise<void>((resolve) => {
setTimeout(async () => {
try {
const serializedData = Buffer.isBuffer(data)
? data
: Buffer.from(JSON.stringify(data), 'utf-8');
trackStoredData(serializedData.byteLength);
const compressedData = await gzip(serializedData, { level: 9 });
trackStoredCompressedData(compressedData.byteLength);
const hash = murmurHash.murmurHash64(cacheKey);
this.gzippedCache.storeSync(hash, compressedData);
await fs.promises.writeFile(this.resolver(hash), compressedData);
} catch (err) {
debug('Failed to persist data', err);
} finally {
resolve();
this.pending.splice(this.pending.indexOf(promise), 1);
}
});
});
this.pending.push(promise);
}
public async load(cacheKey: string, parse = true): Promise<any> {
const hash = murmurHash.murmurHash64(cacheKey);
if (!this.knownFiles.includes(hash)) return null;
const storedData = await this.gzippedCache.load(
hash,
async () => await fs.promises.readFile(this.resolver(hash))
);
trackLoadedCompressedData(storedData.byteLength);
const uncompressedData = await gunzip(storedData);
trackLoadedData(uncompressedData.byteLength);
return parse ? JSON.parse(uncompressedData.toString('utf-8')) : uncompressedData;
}
public static async denormalizeValue(
value: AstroFactoryReturnValue
): Promise<DenormalizationResult<SerializedValue, AstroFactoryReturnValue>> {
if (value instanceof Response) {
return RenderFileStore.denormalizeResponse(value);
}
if (runtime.isHeadAndContent(value)) {
const chunks = await FactoryValueClone.renderTemplateToChunks(value.content);
const seminormalChunks = await Promise.all(chunks.map(RenderFileStore.tryDenormalizeChunk));
const clone = () =>
runtime.createHeadAndContent(
value.head,
FactoryValueClone.renderTemplateFromChunks(chunks)
);
return seminormalChunks.every(Either.isRight)
? {
clone,
denormalized: {
type: 'headAndContent',
head: value.head.toString(),
chunks: seminormalChunks.map((right) => right.value),
},
}
: { clone };
}
// Handle ThinHead - no content to denormalize
if (!runtime.isRenderTemplateResult(value)) {
return { clone: () => value };
}
const chunks = await FactoryValueClone.renderTemplateToChunks(value);
const seminormalChunks = await Promise.all(chunks.map(RenderFileStore.tryDenormalizeChunk));
const clone = () => FactoryValueClone.renderTemplateFromChunks(chunks);
return seminormalChunks.every(Either.isRight)
? {
clone,
denormalized: {
type: 'templateResult',
chunks: seminormalChunks.map((right) => right.value),
},
}
: { clone };
}
private static normalizeValue(value: SerializedValue): ValueThunk {
switch (value.type) {
case 'headAndContent': {
const normalChunks = value.chunks.map(RenderFileStore.normalizeChunk);
return () =>
runtime.createHeadAndContent(
// SAFETY: Astro core is wrong
new runtime.HTMLString(value.head) as unknown as string,
FactoryValueClone.renderTemplateFromChunks(normalChunks)
);
}
case 'templateResult': {
const normalChunks = value.chunks.map(RenderFileStore.normalizeChunk);
return () => FactoryValueClone.renderTemplateFromChunks(normalChunks);
}
case 'response':
return () => RenderFileStore.normalizeResponse(value);
}
}
private static async tryDenormalizeChunk(
chunk: RenderDestinationChunk
): Promise<Either<RenderDestinationChunk, SerializedChunk>> {
const deno = await RenderFileStore.denormalizeChunk(chunk);
return deno === null ? Either.left(chunk) : Either.right(deno);
}
private static async denormalizeChunk(
chunk: RenderDestinationChunk
): Promise<SerializedChunk | null> {
switch (typeof chunk) {
case 'string':
case 'number':
case 'boolean':
return {
type: 'primitive',
value: chunk,
};
case 'object':
break;
default:
debug('Unexpected chunk type', chunk);
return null;
}
if (chunk instanceof runtime.HTMLBytes)
return {
type: 'htmlBytes',
value: Buffer.from(chunk).toString('base64'),
};
if (chunk instanceof runtime.SlotString) {
const instructions = chunk.instructions?.filter(
RenderFileStore.isSerializableRenderInstruction
);
// Some instruction was not serializable
if (instructions?.length !== chunk.instructions?.length) return null;
return {
type: 'slotString',
value: chunk.toString(),
renderInstructions: instructions,
};
}
if (chunk instanceof runtime.HTMLString)
return {
type: 'htmlString',
value: chunk.toString(),
};
if (chunk instanceof Response) {
const { denormalized } = await RenderFileStore.denormalizeResponse(chunk);
return denormalized!;
}
if ('buffer' in chunk)
return {
type: 'arrayBufferView',
value: Buffer.from(
chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
).toString('base64'),
};
if (RenderFileStore.isSerializableRenderInstruction(chunk))
return {
type: 'renderInstruction',
instruction: chunk,
};
debug('Unexpected chunk type', chunk);
return null;
}
private static normalizeChunk(chunk: SerializedChunk): RenderDestinationChunk {
/*
export type RenderDestinationChunk = string | HTMLBytes | HTMLString | SlotString | ArrayBufferView | RenderInstruction | Response;
*/
switch (chunk.type) {
case 'primitive': {
if (chunk.value === undefined) {
throw new Error('Undefined chunk value');
}
return chunk.value as string;
}
case 'htmlString':
return new runtime.HTMLString(chunk.value);
case 'htmlBytes':
return new runtime.HTMLBytes(Buffer.from(chunk.value, 'base64'));
case 'slotString':
return new runtime.SlotString(
chunk.value,
chunk.renderInstructions?.map(RenderFileStore.normalizeRenderInstruction) ?? null
);
case 'renderInstruction':
return RenderFileStore.normalizeRenderInstruction(chunk.instruction);
case 'arrayBufferView': {
const buffer = Buffer.from(chunk.value, 'base64');
return {
buffer: buffer.buffer,
byteLength: buffer.length,
byteOffset: 0,
};
}
case 'response':
return new Response(chunk.body, {
headers: chunk.headers,
});
default:
throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
}
}
private static normalizeRenderInstruction(
instruction: SerializableRenderInstruction
): RenderInstruction {
// SAFETY: `createRenderInstruction` uses an overload to handle the types of each render instruction
// individually. This breaks when the given instruction can be any of them as no overload
// accepts them indistinctivelly. Each individual type matches the output so the following
// is valid.
return runtime.createRenderInstruction(instruction as any);
}
private static async denormalizeResponse(
value: Response
): Promise<
DenormalizationResult<SerializedValue<'response'> & SerializedChunk<'response'>, Response>
> {
const body = await value.arrayBuffer();
return {
denormalized: {
type: 'response',
body: Buffer.from(body).toString('base64'),
status: value.status,
statusText: value.statusText,
headers: Object.fromEntries(value.headers.entries()),
},
clone: () => new Response(body, value),
};
}
private static normalizeResponse(
value: SerializedValue<'response'> | SerializedChunk<'response'>
): Response {
return new Response(value.body, {
headers: value.headers,
status: value.status,
statusText: value.statusText,
});
}
private static isSerializableRenderInstruction(
instruction: RenderInstruction
): instruction is SerializableRenderInstruction {
return !(NON_SERIALIZABLE_RENDER_INSTRUCTIONS as Array<RenderInstruction['type']>).includes(
instruction.type
);
}
}

57
packages/cache/src/utils.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import type { HTMLBytes, HTMLString } from 'astro/runtime/server/index.js';
import type { SlotString } from 'astro/runtime/server/render/slot.js';
import type {
createHeadAndContent,
isHeadAndContent,
} from 'astro/runtime/server/render/astro/head-and-content.js';
import type {
isRenderTemplateResult,
renderTemplate,
} from 'astro/runtime/server/render/astro/render-template.js';
import type { createRenderInstruction } from 'astro/runtime/server/render/instruction.js';
import type { getImage } from 'astro:assets';
import type { renderEntry } from 'astro/content/runtime';
type RuntimeInstances = {
HTMLBytes: typeof HTMLBytes;
HTMLString: typeof HTMLString;
SlotString: typeof SlotString;
createHeadAndContent: typeof createHeadAndContent;
isHeadAndContent: typeof isHeadAndContent;
renderTemplate: typeof renderTemplate;
isRenderTemplateResult: typeof isRenderTemplateResult;
createRenderInstruction: typeof createRenderInstruction;
getImage: typeof getImage;
renderEntry: typeof renderEntry;
};
export const runtime: RuntimeInstances = ((globalThis as any)[
Symbol.for('@domain-expansion:astro-runtime-instances')
] = {} as RuntimeInstances);
export type MaybePromise<T> = Promise<T> | T;
type Left<T> = { variant: 'left'; value: T };
type Right<T> = { variant: 'right'; value: T };
export type Either<L, R> = Left<L> | Right<R>;
export namespace Either {
export function left<T>(value: T): Left<T> {
return { variant: 'left', value };
}
export function right<T>(value: T): Right<T> {
return { variant: 'right', value };
}
export function isLeft<L, R>(either: Either<L, R>): either is Left<L> {
return either.variant === 'left';
}
export function isRight<L, R>(either: Either<L, R>): either is Right<R> {
return either.variant === 'right';
}
}
export type Thunk<T> = () => T;

View File

@ -0,0 +1,66 @@
import { defineTests } from '../common.ts';
await defineTests({
fixtureName: 'basic',
prefix: 'in-memory-component',
integrationOptions: {
cachePages: false,
cacheComponents: 'in-memory',
},
coldMetrics: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
hotMetrics: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
changeFiles: [
{
changes: [
{
path: 'src/other.ts',
updater: 'export const other = "updated transitive value";',
},
],
metricsAfter: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
{
changes: [
{
path: 'src/Component.astro',
updater: '<p>Updated component</p>\n<slot />',
},
],
metricsAfter: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
{
changes: [
{
path: 'src/module.ts',
updater: 'export const value = "updated direct value";',
},
],
metricsAfter: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
],
});

View File

@ -0,0 +1,66 @@
import { defineTests } from '../common.ts';
await defineTests({
fixtureName: 'basic',
prefix: 'persistent-component',
integrationOptions: {
cachePages: false,
cacheComponents: 'persistent',
},
coldMetrics: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
hotMetrics: {
'fs-cache-hit': 4,
'fs-cache-miss': 0,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
changeFiles: [
{
changes: [
{
path: 'src/other.ts',
updater: 'export const other = "updated transitive value";',
},
],
metricsAfter: {
'fs-cache-hit': 4,
'fs-cache-miss': 0,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
{
changes: [
{
path: 'src/Component.astro',
updater: '<p>Updated component</p>\n<slot />',
},
],
metricsAfter: {
'fs-cache-hit': 0,
'fs-cache-miss': 2,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
{
changes: [
{
path: 'src/module.ts',
updater: 'export const value = "updated direct value";',
},
],
metricsAfter: {
'fs-cache-hit': 4,
'fs-cache-miss': 0,
'in-memory-cache-hit': 4,
'in-memory-cache-miss': 4,
},
},
],
});

View File

@ -0,0 +1,66 @@
import { defineTests } from '../common.ts';
await defineTests({
fixtureName: 'basic',
prefix: 'persistent-component',
integrationOptions: {
cachePages: true,
cacheComponents: false,
},
coldMetrics: {
'fs-cache-hit': 0,
'fs-cache-miss': 7,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 14,
},
hotMetrics: {
'fs-cache-hit': 14,
'fs-cache-miss': 0,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 14,
},
changeFiles: [
{
changes: [
{
path: 'src/other.ts',
updater: 'export const other = "updated transitive value";',
},
],
metricsAfter: {
'fs-cache-hit': 10,
'fs-cache-miss': 2,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 14,
},
},
{
changes: [
{
path: 'src/Component.astro',
updater: '<p>Updated component</p>\n<slot />',
},
],
metricsAfter: {
'fs-cache-hit': 12,
'fs-cache-miss': 0,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 14,
},
},
{
changes: [
{
path: 'src/module.ts',
updater: 'export const value = "updated direct value";',
},
],
metricsAfter: {
'fs-cache-hit': 12,
'fs-cache-miss': 1,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 14,
},
},
],
});

206
packages/cache/tests/common.ts vendored Normal file
View File

@ -0,0 +1,206 @@
import { loadFixture } from '@inox-tools/astro-tests/astroFixture';
import { integration, INTEGRATION_NAME } from '../src/integration.js';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import type { AstroInlineConfig } from 'astro';
import { rm } from 'node:fs/promises';
import { clearMetrics, collectMetrics, type CollectedMetrics } from '../src/metrics.js';
import assert from 'node:assert';
export type IntegrationOptions = NonNullable<Parameters<typeof integration>[0]>;
export type Fixture = Awaited<ReturnType<typeof loadFixture>>;
export type ChangeFile = {
path: string;
updater: Parameters<Fixture['editFile']>[1];
};
export type ChangesetTest = {
changes: ChangeFile[];
metricsAfter: Partial<CollectedMetrics>;
};
type TestOptions = {
fixtureName: string;
prefix: string;
coldMetrics: Partial<CollectedMetrics>;
hotMetrics: Partial<CollectedMetrics>;
integrationOptions?: Omit<IntegrationOptions, 'prefix'>;
testName?: string;
config?: Omit<AstroInlineConfig, 'root'>;
/**
* Change source files on the test
*/
changeFiles?: ChangesetTest[];
};
export async function defineTests(options: TestOptions): Promise<void> {
const fixture = await loadFixture({
root: `./fixture/${options.fixtureName}`,
outDir: `./dist/${options.prefix}`,
});
const scenarioName = options.testName || `[${options.fixtureName}] Equivalence check`;
describe(scenarioName, () => {
beforeAll(async () => {
const cachePath = new URL('./node_modules/.domain-expansion', fixture.config.root);
await rm(cachePath, { force: true, recursive: true });
const outDir = new URL(`./dist/${options.prefix}`, fixture.config.root);
await rm(outDir, { force: true, recursive: true });
});
// afterAll(async () => {
// const cachePath = new URL(
// `./node_modules/.domain-expansion/${options.prefix}`,
// fixture.config.root,
// );
// await rm(cachePath, { force: true, recursive: true });
// const outDir = new URL(`./dist/${options.prefix}`, fixture.config.root);
// await rm(outDir, { force: true, recursive: true });
// await fixture.clean();
// });
afterEach(() => {
fixture.resetAllFiles();
});
test('all files should be identical', async () => {
await fixture.build({
...withoutDomainExpansion(options.config),
outDir: `./dist/${options.prefix}/normal`,
});
const configWithDomainExpansion = withDomainExpansion(options.config, {
...options.integrationOptions,
cachePrefix: `${options.prefix}/base`,
});
clearMetrics();
await fixture.build({
...configWithDomainExpansion,
outDir: `./dist/${options.prefix}/cold`,
});
const coldMetrics = collectMetrics();
clearMetrics();
await fixture.build({
...configWithDomainExpansion,
outDir: `./dist/${options.prefix}/hot`,
});
const hotMetrics = collectMetrics();
await checkIdenticalFiles(fixture, ['normal', 'cold', 'hot']);
expect(coldMetrics).toEqual(expect.objectContaining(options.coldMetrics));
expect(hotMetrics).toEqual(expect.objectContaining(options.hotMetrics));
});
const { changeFiles: changesets } = options;
if (changesets) {
for (const [index, changeset] of changesets.entries()) {
const name = changeset.changes.length === 1 ? changeset.changes[0]!.path : index;
test(`should match normal build after file changes - ${name}`, async () => {
// Prime cache
await fixture.build(
withDomainExpansion(
{
...options.config,
outDir: `./dist/${options.prefix}/changed-cached`,
},
{
...options.integrationOptions,
cachePrefix: `${options.prefix}/changed-${index}`,
}
)
);
for (const change of changeset.changes) {
await fixture.editFile(change.path, change.updater);
}
await fixture.build({
...withoutDomainExpansion(options.config),
outDir: `./dist/${options.prefix}/changed-normal`,
});
clearMetrics();
await fixture.build(
withDomainExpansion(
{
...options.config,
outDir: `./dist/${options.prefix}/changed-cached`,
},
{
...options.integrationOptions,
cachePrefix: `${options.prefix}/changed-${index}`,
}
)
);
const metrics = collectMetrics();
await checkIdenticalFiles(fixture, ['changed-normal', 'changed-cached']);
expect(metrics).toEqual(expect.objectContaining(changeset.metricsAfter));
});
}
}
});
}
export async function checkIdenticalFiles(
fixture: Fixture,
[referenceVariant, ...otherVariants]: [string, string, ...string[]]
): Promise<void> {
const variantFiles: Record<string, string[]> = {};
for (const file of await fixture.glob('**')) {
const match = file.match(/(.+?)\/(.*)/);
assert.ok(match);
const [, variant, fileName] = match as [string, string, string];
if (!variantFiles[variant]) variantFiles[variant] = [];
variantFiles[variant].push(fileName);
}
const referenceFiles = variantFiles[referenceVariant];
assert.ok(referenceFiles);
for (const variant of otherVariants) {
// Arrays can be in different orders
expect(variantFiles[variant]).toIncludeAllMembers(referenceFiles);
}
for (const fileName of referenceFiles) {
const referenceFile = await fixture.readFile(`${referenceVariant}/${fileName}`);
for (const variant of otherVariants) {
const variantFile = await fixture.readFile(`${variant}/${fileName}`);
expect(variantFile, fileName).toEqual(referenceFile);
}
}
}
function withoutDomainExpansion(config: AstroInlineConfig = {}): AstroInlineConfig {
if (!config.integrations) return config;
return {
...config,
integrations: config.integrations.flat().filter((int) => int && int.name !== INTEGRATION_NAME),
};
}
function withDomainExpansion(
config: AstroInlineConfig = {},
options?: IntegrationOptions
): AstroInlineConfig {
if (!config.integrations)
return {
...config,
integrations: [integration(options)],
};
return {
...withoutDomainExpansion(config),
integrations: [...config.integrations, integration(options)],
};
}

View File

@ -0,0 +1,2 @@
*/build/
*/.astro/

View File

@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';
export default defineConfig({});

View File

@ -0,0 +1,10 @@
{
"name": "@domain-expansion-test/basic",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@domain-expansion/astro": "workspace:",
"astro": "catalog:"
}
}

View File

@ -0,0 +1,13 @@
---
export interface Props {
value?: string;
}
---
<p>
Prop value: {Astro.props.value}
</p>
<div>
Children:
<slot />
</div>

View File

@ -0,0 +1,3 @@
import { other } from './other.js';
export const value = `value from a TS module - ${other}`;

View File

@ -0,0 +1 @@
export const other = 'anonther transitive value';

View File

@ -0,0 +1,12 @@
---
export function getStaticPaths() {
return [{ params: { slug: 'foo' } }, { params: { slug: 'bar/baz' } }];
}
const { slug } = Astro.params;
---
<div>
A dynamic page with rest parameter:
<p>{slug}</p>
</div>

View File

@ -0,0 +1,12 @@
---
export function getStaticPaths() {
return [{ params: { slug: 'apple' } }, { params: { slug: 'banana' } }];
}
const { slug } = Astro.params;
---
<div>
A dynamic page with simple parameter:
<p>{slug}</p>
</div>

View File

@ -0,0 +1,18 @@
---
import Comp from '../Component.astro';
import { value } from '../module.js';
---
<html>
<head>
<title>Complete page</title>
</head>
<body>
<Comp value="one" />
<p>{value}</p>
<Comp value="two" />
<Comp>
<p>three</p>
</Comp>
</body>
</html>

View File

@ -0,0 +1,18 @@
---
import Comp from '../Component.astro';
import { other } from '../other.js';
---
<html>
<head>
<title>Another complete page</title>
</head>
<body>
<Comp value="one" />
<p>{other}</p>
<Comp value="two" />
<Comp>
<p>three</p>
</Comp>
</body>
</html>

View File

@ -0,0 +1 @@
<p>This is a simple page with automatically generated surrounding elements</p>

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
compressHTML: false,
integrations: [
starlight({
title: 'Example docs',
pagefind: false,
}),
],
});

View File

@ -0,0 +1,12 @@
{
"name": "@domain-expansion-test/basic",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@astrojs/starlight": "^0.30.0",
"@domain-expansion/astro": "workspace:",
"astro": "catalog:",
"sharp": "^0.32.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,6 @@
---
title: Not found
template: splash
---
# Page not found

View File

@ -0,0 +1,29 @@
---
title: Sample page
head:
- tag: title
content: Starlight 🌟 Build documentation sites with Astro
description: Starlight helps you build beautiful, high-performance documentation websites with Astro.
template: splash
editUrl: false
lastUpdated: false
banner:
content: |
Updating to Astro 5?
<a href="https://github.com/withastro/starlight/releases/tag/%40astrojs/starlight%400.30.0">
Learn how to upgrade
</a>
hero:
title: Make your docs shine with Starlight
tagline: Everything you need to build a stellar documentation website. Fast, accessible, and easy-to-use.
image:
file: ../../assets/hero-star.webp
actions:
- text: Get started
icon: right-arrow
link: /getting-started/
- text: View on GitHub
icon: external
variant: minimal
link: https://github.com/withastro/starlight
---

View File

@ -0,0 +1,513 @@
---
title: Authoring Content in Markdown
template: splash
description: An overview of the Markdown syntax Starlight supports.
---
Starlight supports the full range of [Markdown](https://daringfireball.net/projects/markdown/) syntax in `.md` files as well as frontmatter [YAML](https://dev.to/paulasantamaria/introduction-to-yaml-125f) to define metadata such as a title and description.
Please be sure to check the [MDX docs](https://mdxjs.com/docs/what-is-mdx/#markdown) or [Markdoc docs](https://markdoc.dev/docs/syntax) if using those file formats, as Markdown support and usage can differ.
## Frontmatter
You can customize individual pages in Starlight by setting values in their frontmatter.
Frontmatter is set at the top of your files between `---` separators:
```md title="src/content/docs/example.md"
---
title: My page title
---
Page content follows the second `---`.
```
Every page must include at least a `title`.
See the [frontmatter reference](/reference/frontmatter/) for all available fields and how to add custom fields.
## Inline styles
Text can be **bold**, _italic_, or ~~strikethrough~~.
```md
Text can be **bold**, _italic_, or ~~strikethrough~~.
```
You can [link to another page](/getting-started/).
```md
You can [link to another page](/getting-started/).
```
You can highlight `inline code` with backticks.
```md
You can highlight `inline code` with backticks.
```
## Images
Images in Starlight use [Astros built-in optimized asset support](https://docs.astro.build/en/guides/images/).
Markdown and MDX support the Markdown syntax for displaying images that includes alt-text for screen readers and assistive technology.
![An illustration of planets and stars featuring the word “astro”](https://raw.githubusercontent.com/withastro/docs/main/public/default-og-image.png)
```md
![An illustration of planets and stars featuring the word “astro”](https://raw.githubusercontent.com/withastro/docs/main/public/default-og-image.png)
```
![Sample test](../../assets/hero-star.webp)
Relative image paths are also supported for images stored locally in your project.
```md
// src/content/docs/page-1.md
![A rocketship in space](../../assets/images/rocket.svg)
```
## Headings
You can structure content using a heading. Headings in Markdown are indicated by a number of `#` at the start of the line.
### How to structure page content in Starlight
Starlight is configured to automatically use your page title as a top-level heading and will include an "Overview" heading at top of each page's table of contents. We recommend starting each page with regular paragraph text content and using on-page headings from `<h2>` and down:
```md
---
title: Markdown Guide
description: How to use Markdown in Starlight
---
This page describes how to use Markdown in Starlight.
## Inline Styles
## Headings
```
### Automatic heading anchor links
Using headings in Markdown will automatically give you anchor links so you can link directly to certain sections of your page:
```md
---
title: My page of content
description: How to use Starlight's built-in anchor links
---
## Introduction
I can link to [my conclusion](#conclusion) lower on the same page.
## Conclusion
`https://my-site.com/page1/#introduction` navigates directly to my Introduction.
```
Level 2 (`<h2>`) and Level 3 (`<h3>`) headings will automatically appear in the page table of contents.
Learn more about how Astro processes heading `id`s in [the Astro Documentation](https://docs.astro.build/en/guides/markdown-content/#heading-ids)
## Asides
Asides (also known as “admonitions” or “callouts”) are useful for displaying secondary information alongside a pages main content.
Starlight provides a custom Markdown syntax for rendering asides. Aside blocks are indicated using a pair of triple colons `:::` to wrap your content, and can be of type `note`, `tip`, `caution` or `danger`.
You can nest any other Markdown content types inside an aside, but asides are best suited to short and concise chunks of content.
### Note aside
:::note
Starlight is a documentation website toolkit built with [Astro](https://astro.build/). You can get started with this command:
```sh
npm create astro@latest -- --template starlight
```
:::
````md
:::note
Starlight is a documentation website toolkit built with [Astro](https://astro.build/). You can get started with this command:
```sh
npm create astro@latest -- --template starlight
```
:::
````
### Custom aside titles
You can specify a custom title for the aside in square brackets following the aside type, e.g. `:::tip[Did you know?]`.
:::tip[Did you know?]
Astro helps you build faster websites with [“Islands Architecture”](https://docs.astro.build/en/concepts/islands/).
:::
```md
:::tip[Did you know?]
Astro helps you build faster websites with [“Islands Architecture”](https://docs.astro.build/en/concepts/islands/).
:::
```
### More aside types
Caution and danger asides are helpful for drawing a users attention to details that may trip them up.
If you find yourself using these a lot, it may also be a sign that the thing you are documenting could benefit from being redesigned.
:::caution
If you are not sure you want an awesome docs site, think twice before using [Starlight](/).
:::
:::danger
Your users may be more productive and find your product easier to use thanks to helpful Starlight features.
- Clear navigation
- User-configurable colour theme
- [i18n support](/guides/i18n/)
:::
```md
:::caution
If you are not sure you want an awesome docs site, think twice before using [Starlight](/).
:::
:::danger
Your users may be more productive and find your product easier to use thanks to helpful Starlight features.
- Clear navigation
- User-configurable colour theme
- [i18n support](/guides/i18n/)
:::
```
## Blockquotes
> This is a blockquote, which is commonly used when quoting another person or document.
>
> Blockquotes are indicated by a `>` at the start of each line.
```md
> This is a blockquote, which is commonly used when quoting another person or document.
>
> Blockquotes are indicated by a `>` at the start of each line.
```
## Code blocks
A code block is indicated by a block with three backticks <code>```</code> at the start and end. You can indicate the programming language being used after the opening backticks.
```js
// Javascript code with syntax highlighting.
var fun = function lang(l) {
dateformat.i18n = require('./lang/' + l);
return true;
};
```
````md
```js
// Javascript code with syntax highlighting.
var fun = function lang(l) {
dateformat.i18n = require('./lang/' + l);
return true;
};
```
````
### Expressive Code features
Starlight uses [Expressive Code](https://github.com/expressive-code/expressive-code/tree/main/packages/astro-expressive-code) to extend formatting possibilities for code blocks.
Expressive Codes text markers and window frames plugins are enabled by default.
Code block rendering can be configured using Starlights [`expressiveCode` configuration option](/reference/configuration/#expressivecode).
#### Text markers
You can highlight specific lines or parts of your code blocks using [Expressive Code text markers](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md#usage-in-markdown--mdx-documents) on the opening line of your code block.
Use curly braces (`{ }`) to highlight entire lines, and quotation marks to highlight strings of text.
There are three highlighting styles: neutral for calling attention to code, green for indicating inserted code, and red for indicating deleted code.
Both text and entire lines can be marked using the default marker, or in combination with `ins=` and `del=` to produce the desired highlighting.
Expressive Code provides several options for customizing the visual appearance of your code samples.
Many of these can be combined, for highly illustrative code samples.
Please explore the [Expressive Code documentation](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md) for the extensive options available.
Some of the most common examples are shown below:
- [Mark entire lines & line ranges using the `{ }` marker](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md#marking-entire-lines--line-ranges):
```js {2-3}
function demo() {
// This line (#2) and the next one are highlighted
return 'This is line #3 of this snippet';
}
```
````md
```js {2-3}
function demo() {
// This line (#2) and the next one are highlighted
return 'This is line #3 of this snippet';
}
```
````
- [Mark selections of text using the `" "` marker or regular expressions](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md#marking-individual-text-inside-lines):
```js "Individual terms" /Even.*supported/
// Individual terms can be highlighted, too
function demo() {
return 'Even regular expressions are supported';
}
```
````md
```js "Individual terms" /Even.*supported/
// Individual terms can be highlighted, too
function demo() {
return 'Even regular expressions are supported';
}
```
````
- [Mark text or lines as inserted or deleted with `ins` or `del`](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md#selecting-marker-types-mark-ins-del):
```js "return true;" ins="inserted" del="deleted"
function demo() {
console.log('These are inserted and deleted marker types');
// The return statement uses the default marker type
return true;
}
```
````md
```js "return true;" ins="inserted" del="deleted"
function demo() {
console.log('These are inserted and deleted marker types');
// The return statement uses the default marker type
return true;
}
```
````
- [Combine syntax highlighting with `diff`-like syntax](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-text-markers/README.md#combining-syntax-highlighting-with-diff-like-syntax):
```diff lang="js"
function thisIsJavaScript() {
// This entire block gets highlighted as JavaScript,
// and we can still add diff markers to it!
- console.log('Old code to be removed')
+ console.log('New and shiny code!')
}
```
````md
```diff lang="js"
function thisIsJavaScript() {
// This entire block gets highlighted as JavaScript,
// and we can still add diff markers to it!
- console.log('Old code to be removed')
+ console.log('New and shiny code!')
}
```
````
#### Frames and titles
Code blocks can be rendered inside a window-like frame.
A frame that looks like a terminal window will be used for shell scripting languages (e.g. `bash` or `sh`).
Other languages display inside a code editor-style frame if they include a title.
A code blocks optional title can be set either with a `title="..."` attribute following the code block's opening backticks and language identifier, or with a file name comment in the first lines of the code.
- [Add a file name tab with a comment](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-frames/README.md#adding-titles-open-file-tab-or-terminal-window-title)
```js
// my-test-file.js
console.log('Hello World!');
```
````md
```js
// my-test-file.js
console.log('Hello World!');
```
````
- [Add a title to a Terminal window](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-frames/README.md#adding-titles-open-file-tab-or-terminal-window-title)
```bash title="Installing dependencies…"
npm install
```
````md
```bash title="Installing dependencies…"
npm install
```
````
- [Disable window frames with `frame="none"`](https://github.com/expressive-code/expressive-code/blob/main/packages/%40expressive-code/plugin-frames/README.md#overriding-frame-types)
```bash frame="none"
echo "This is not rendered as a terminal despite using the bash language"
```
````md
```bash frame="none"
echo "This is not rendered as a terminal despite using the bash language"
```
````
## Details
Details (also known as “disclosures” or “accordions”) are useful to hide content that is not immediately relevant.
Users can click a short summary to expand and view the full content.
Use the standard HTML [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) and [`<summary>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/summary) elements in your Markdown content to create a disclosure widget.
You can nest any other Markdown syntax inside a `<details>` element.
<details>
<summary>Where and when is the Andromeda constellation most visible?</summary>
The [Andromeda constellation](<https://en.wikipedia.org/wiki/Andromeda_(constellation)>) is most visible in the night sky during the month of November at latitudes between `+90°` and `40°`.
</details>
```md
<details>
<summary>Where and when is the Andromeda constellation most visible?</summary>
The [Andromeda constellation](<https://en.wikipedia.org/wiki/Andromeda_(constellation)>) is most visible in the night sky during the month of November at latitudes between `+90°` and `40°`.
</details>
```
## Other common Markdown features
Starlight supports all other Markdown authoring syntax, such as lists and tables. See the [Markdown Cheat Sheet from The Markdown Guide](https://www.markdownguide.org/cheat-sheet/) for a quick overview of all the Markdown syntax elements.
## Advanced Markdown and MDX configuration
Starlight uses Astros Markdown and MDX renderer built on remark and rehype. You can add support for custom syntax and behavior by adding `remarkPlugins` or `rehypePlugins` in your Astro config file. See [“Markdown Plugins”](https://docs.astro.build/en/guides/markdown-content/#markdown-plugins) in the Astro docs to learn more.
## Markdoc
Starlight supports authoring content in Markdoc using the experimental [Astro Markdoc integration](https://docs.astro.build/en/guides/integrations-guide/markdoc/) and the Starlight Markdoc preset.
### Create a new project with Markdoc
Start a new Starlight project with Markdoc pre-configured using `create astro`:
import { Tabs, TabItem, Steps } from '@astrojs/starlight/components';
<Tabs syncKey="pkg">
<TabItem label="npm">
```sh
npm create astro@latest -- --template starlight/markdoc
```
</TabItem>
<TabItem label="pnpm">
```sh
pnpm create astro --template starlight/markdoc
```
</TabItem>
<TabItem label="Yarn">
```sh
yarn create astro --template starlight/markdoc
```
</TabItem>
</Tabs>
### Add Markdoc to an existing project
If you already have a Starlight site and want to add Markdoc, follow these steps.
<Steps>
1. Add Astros Markdoc integration:
<Tabs syncKey="pkg">
<TabItem label="npm">
```sh
npx astro add markdoc
```
</TabItem>
<TabItem label="pnpm">
```sh
pnpm astro add markdoc
```
</TabItem>
<TabItem label="Yarn">
```sh
yarn astro add markdoc
```
</TabItem>
</Tabs>
2. Install the Starlight Markdoc preset:
<Tabs syncKey="pkg">
<TabItem label="npm">
```sh
npm install @astrojs/starlight-markdoc
```
</TabItem>
<TabItem label="pnpm">
```sh
pnpm add @astrojs/starlight-markdoc
```
</TabItem>
<TabItem label="Yarn">
```sh
yarn add @astrojs/starlight-markdoc
```
</TabItem>
</Tabs>
3. Create a Markdoc configuration file at `markdoc.config.mjs` and use the Starlight Markdoc preset:
```js
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import starlightMarkdoc from '@astrojs/starlight-markdoc';
export default defineMarkdocConfig({
extends: [starlightMarkdoc()],
});
```
</Steps>
To learn more about the Markdoc syntax and features, see the [Markdoc documentation](https://markdoc.dev/docs/syntax) or the [Astro Markdoc integration guide](https://docs.astro.build/en/guides/integrations-guide/markdoc/).

View File

@ -0,0 +1,7 @@
---
title: Simple page
template: splash
description: A simple MD page
---
Simple MD page

View File

@ -0,0 +1,39 @@
import { defineTests } from '../common.ts';
await defineTests({
fixtureName: 'starlight',
prefix: 'starlight',
integrationOptions: {
cachePages: false,
cacheComponents: 'persistent',
componentsHaveSharedState: true,
},
coldMetrics: {
'fs-cache-hit': 0,
'fs-cache-miss': 98,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 196,
},
hotMetrics: {
'fs-cache-hit': 8,
'fs-cache-miss': 0,
'in-memory-cache-hit': 0,
'in-memory-cache-miss': 8,
},
changeFiles: [
{
changes: [
{
path: 'src/content/docs/index.md',
updater: '---\ntitle: Sample page\n---\n\nupdated page',
},
],
metricsAfter: {
'fs-cache-hit': 16,
'fs-cache-miss': 30,
'in-memory-cache-hit': 2,
'in-memory-cache-miss': 76,
},
},
],
});

6
packages/cache/tests/vitest.setup.ts vendored Normal file
View File

@ -0,0 +1,6 @@
import * as matchers from 'jest-extended';
import { expect } from 'vitest';
process.setSourceMapsEnabled(true);
expect.extend(matchers);

18
packages/cache/tsconfig.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "astro/tsconfigs/strictest",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"verbatimModuleSyntax": true,
"jsx": "preserve",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src/**/*", "env.d.ts"],
"exclude": ["dist", "node_modules", "tests"]
}

27
packages/cache/tsup.config.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'tsup';
import { peerDependencies, dependencies, devDependencies } from './package.json';
export default defineConfig((options) => {
const dev = !!options.watch;
return {
entry: ['src/index.ts'],
format: ['esm'],
target: 'node18',
bundle: true,
dts: true,
sourcemap: true,
clean: true,
splitting: false,
minify: !dev,
noExternal: Object.keys(devDependencies),
external: [
...Object.keys(peerDependencies),
...Object.keys(dependencies),
/node_modules/g,
'recast',
'tslib',
],
tsconfig: 'tsconfig.json',
treeshake: 'smallest',
};
});

25
packages/cache/vitest.config.mjs vendored Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
process.env.NODE_OPTIONS ??= '--enable-source-maps';
process.setSourceMapsEnabled(true);
export default defineConfig({
keepProcessEnv: true,
test: {
setupFiles: ['./tests/vitest.setup.ts'],
maxConcurrency: 1,
maxWorkers: 1,
minWorkers: 1,
testTimeout: 60_000,
// hookTimeout: 30000,
// pool: 'forks',
// poolOptions: {
// forks: {
// isolate: true
// }
// },
coverage: {
provider: 'v8',
},
},
});

View File

@ -1,21 +0,0 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View File

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View File

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,59 +0,0 @@
# docs
## 0.0.1-beta.6
### Patch Changes
- Updated dependencies [cf9e4ea]
- @domain-expansion/astro@0.1.0-beta.7
## 0.0.1-beta.5
### Patch Changes
- Updated dependencies [8684bcb]
- @domain-expansion/astro@0.1.0-beta.6
## 0.0.1-beta.4
### Patch Changes
- Updated dependencies [4309f3e]
- @domain-expansion/astro@0.1.0-beta.5
## 0.0.1-beta.3
### Patch Changes
- Updated dependencies [1278f19]
- @domain-expansion/astro@0.1.0-beta.4
## 0.0.1-beta.2
### Patch Changes
- Updated dependencies
- Updated dependencies [641dfce]
- Updated dependencies [7f93ad2]
- @domain-expansion/astro@0.1.0-beta.3
## 0.0.1-beta.1
### Patch Changes
- Updated dependencies
- @domain-expansion/astro@0.1.0-beta.2
## 0.0.1-beta.0
### Patch Changes
- Updated dependencies [095445d]
- @domain-expansion/astro@0.1.0-beta.1
## 0.0.2-beta.0
### Patch Changes
- Updated dependencies [76702d0]
- @domain-expansion/astro@0.1.0-beta.0

View File

@ -1,54 +0,0 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── content/
│ │ ├── docs/
│ └── content.config.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

View File

@ -1,52 +0,0 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import catppuccin from 'starlight-theme-catppuccin';
import domainExpansion from '@domain-expansion/astro';
import node from '@astrojs/node';
import starlightImageZoomPlugin from 'starlight-image-zoom';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
site: 'https://domainexpansion.gg',
server: {
host: '0.0.0.0',
},
integrations: [
domainExpansion(),
starlight({
title: 'Domain Expansion',
social: {
github: 'https://github.com/astro-expansion/domain-expansion',
},
sidebar: [
{ label: 'Installation', slug: '' },
{ label: 'Configuration', slug: 'configuration' },
{ label: 'Deploying', slug: 'deploying' },
{ label: 'The Tale of the Three Mages', slug: 'the-tale-of-the-three-mages' },
{ label: 'An actual explanation of what is going on here', slug: 'actual-explanation' },
{ label: 'Caveats', slug: 'caveats' },
{ label: 'El funny', slug: 'memes' },
],
plugins: [
catppuccin({ dark: 'mocha-teal', light: 'latte-teal' }),
starlightImageZoomPlugin(),
],
components: {
Head: './src/overrides/Head.astro',
},
customCss: ['src/styles/globals.css'],
}),
react(),
tailwind({ applyBaseStyles: false }),
],
adapter: node({
mode: 'standalone',
}),
});

View File

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -1,37 +0,0 @@
{
"name": "docs",
"type": "module",
"private": true,
"version": "0.0.1-beta.6",
"scripts": {
"dev": "astro dev",
"start": "node ./dist/server/entry.mjs",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.0.0",
"@astrojs/react": "^4.1.2",
"@astrojs/starlight": "^0.30.0",
"@astrojs/starlight-tailwind": "^3.0.0",
"@astrojs/tailwind": "^5.1.4",
"@domain-expansion/astro": "workspace:^",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"astro": "^5.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"pretty-ms": "^9.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"sharp": "^0.32.5",
"starlight-image-zoom": "^0.9.0",
"starlight-theme-catppuccin": "^2.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7"
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,116 +0,0 @@
---
import { getCollection } from 'astro:content';
import { BenchmarkChart } from '../other/BenchmarkChart.tsx';
const results = await getCollection('benchmark');
---
<ul class="legend not-content">
<li class="legend-item">
<span class="legend-color teal"></span>
<span>With Domain Expansion enabled, subsequent builds (with cache)</span>
</li>
<li class="legend-item">
<span class="legend-color sapphire"></span>
<span>With Domain Expansion enabled, initial build</span>
</li>
<li class="legend-item">
<span class="legend-color lavender"></span>
<span>Without Domain Expansion</span>
</li>
</ul>
<div class="benchmarks-grid not-content">
{
results.map((result) => (
<div class="benchmark-card">
<div class="benchmark-result">
<BenchmarkChart results={result} client:only />
</div>
<div class="benchmark-card-footer">
<span>{result.data.name}</span>
<a href={result.data.url}>{result.data.url}</a>
</div>
</div>
))
}
</div>
<style>
.benchmarks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.benchmark-card {
border: 1px solid var(--sl-color-gray-5);
min-height: calc(250px + 1rem + 2px);
border-radius: 4px;
}
.benchmark-result {
min-height: calc(250px + 1rem + 2px);
padding: 0.5rem;
}
.benchmark-card-footer {
border-top: 1px solid var(--sl-color-gray-5);
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.benchmark-card-footer a {
font-size: 0.875rem;
width: fit-content;
text-decoration: none;
}
.benchmark-card-footer a:hover {
text-decoration: underline;
}
.benchmark-card-footer span {
font-size: 1.25rem;
font-weight: 500;
}
.legend {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.legend-item span {
font-size: 0.875rem;
}
.legend-color {
width: 1rem;
height: 1rem;
min-width: 1rem;
border-radius: 4px;
display: inline-block;
margin-right: 0.5rem;
top: 3px;
position: relative;
}
.teal {
background-color: #94e2d5;
}
.sapphire {
background-color: #74c7ec;
}
.lavender {
background-color: #b4befe;
}
</style>

View File

@ -1,98 +0,0 @@
'use client';
import { Bar, BarChart, XAxis } from 'recharts';
import prettyMs from 'pretty-ms';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import type { CollectionEntry } from 'astro:content';
import { cn } from '@/lib/utils';
const chartConfig = {
duration: {
label: 'Time to build',
color: '#b4befe',
},
} satisfies ChartConfig;
const keyMap = {
standard: {
label: 'Without DE',
color: '#b4befe',
},
cold: {
label: 'With DE, first run',
color: '#74c7ec',
},
hot: {
label: 'With DE, sub. runs',
color: '#94e2d5',
},
};
export function BenchmarkChart({ results }: { results: CollectionEntry<'benchmark'> }) {
const formatted = Object.keys(results.data.benchmark.means)
.map((key) => ({
name: key,
duration: results.data.benchmark.means[key as keyof typeof results.data.benchmark.means].mean,
stdDev: results.data.benchmark.means[key as keyof typeof results.data.benchmark.means].stdDev,
color: keyMap[key as keyof typeof keyMap].color,
}))
.sort((a, b) => {
// name: hot > cold > standard
if (a.name === 'hot') return 1;
if (b.name === 'hot') return -1;
if (a.name === 'cold') return 1;
if (b.name === 'cold') return -1;
return 0;
})
.reverse();
return (
<ChartContainer config={chartConfig} className="min-h-[250px] w-full">
<BarChart barGap={8} accessibilityLayer data={formatted}>
<XAxis
dataKey="name"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => keyMap[value as keyof typeof keyMap].label}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelKey="name"
className="recharts-tooltip"
formatter={(value) => (
<div style={{ display: 'flex', gap: '.5rem', alignItems: 'center' }}>
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg], h-2.5 w-2.5 relative top-px'
)}
style={
{
'--color': formatted.find((x) => x.duration === value)?.color || '#b4befe',
background: 'var(--color)',
border: '1px solid var(--color)',
} as React.CSSProperties
}
/>
{prettyMs(Math.floor(value as number) * 1000)} ±{' '}
{prettyMs(
Math.floor((formatted.find((x) => x.duration === value)?.stdDev || 0) * 1000)
)}
</div>
)}
/>
}
/>
<Bar barSize={70} dataKey="duration" fill="var(--color-duration)" radius={4} />
</BarChart>
</ChartContainer>
);
}

View File

@ -1,55 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -1,327 +0,0 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center'
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -1,34 +0,0 @@
import { defineCollection, z } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
import { file } from 'astro/loaders';
const benchmarkSchema = z.object({
id: z.number(),
name: z.string(),
url: z.string(),
benchmark: z.object({
means: z.object({
standard: z.object({
mean: z.number(),
stdDev: z.number(),
}),
cold: z.object({
mean: z.number(),
stdDev: z.number(),
}),
hot: z.object({
mean: z.number(),
stdDev: z.number(),
}),
}),
}),
});
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
benchmark: defineCollection({
loader: file('src/content/benchmark/results.json'),
schema: benchmarkSchema,
}),
};

View File

@ -1,128 +0,0 @@
[
{
"id": 1,
"name": "Astro Docs",
"url": "https://docs.astro.build",
"benchmark": {
"means": {
"standard": {
"mean": 263.283,
"stdDev": 13.774
},
"cold": {
"mean": 272.885,
"stdDev": 15.867
},
"hot": {
"mean": 143.194,
"stdDev": 4.299
}
}
}
},
{
"id": 2,
"name": "astro.build",
"url": "https://astro.build",
"benchmark": {
"means": {
"standard": {
"mean": 30.852,
"stdDev": 3.303
},
"cold": {
"mean": 31.298,
"stdDev": 2.398
},
"hot": {
"mean": 30.66,
"stdDev": 1.442
}
}
}
},
{
"id": 3,
"name": "Starlight Docs",
"url": "https://starlight.astro.build",
"benchmark": {
"means": {
"standard": {
"mean": 34.021,
"stdDev": 2.99
},
"cold": {
"mean": 35.049,
"stdDev": 1.906
},
"hot": {
"mean": 29.722,
"stdDev": 3.016
}
}
}
},
{
"id": 4,
"name": "StudioCMS UI",
"url": "https://ui.studiocms.dev",
"benchmark": {
"means": {
"standard": {
"mean": 10.523,
"stdDev": 0.455
},
"cold": {
"mean": 10.721,
"stdDev": 0.472
},
"hot": {
"mean": 9.826,
"stdDev": 0.462
}
}
}
},
{
"id": 5,
"name": "Zen Browser",
"url": "https://zen-browser.app",
"benchmark": {
"means": {
"standard": {
"mean": 14.983,
"stdDev": 0.631
},
"cold": {
"mean": 17.88,
"stdDev": 0.603
},
"hot": {
"mean": 17.49,
"stdDev": 0.469
}
}
}
},
{
"id": 6,
"name": "Brutal",
"url": "https://brutal.elian.codes",
"benchmark": {
"means": {
"standard": {
"mean": 4.491,
"stdDev": 0.312
},
"cold": {
"mean": 4.638,
"stdDev": 0.298
},
"hot": {
"mean": 4.492,
"stdDev": 0.266
}
}
}
}
]

View File

@ -1,137 +0,0 @@
---
title: An actual explanation of what is going on here
---
After reading the Tale of the Three Mages, you might be a little confused, so here's an actual explanation of how
Domain Expansion works under the hood.
## How Astro builds your site
Whenever you run `astro build`, Astro will essentially "request" your components internally and save the
resulting HTML. This is done using a function called `$$createComponent`. This function takes in a callback
(the compiled version of your component) and turns it into an instance of a component. That instance is then called
each time the component is rendered, with your properties, slots and so on.
You can see how this looks internally in the Astro runtime [here](https://live-astro-compiler.vercel.app/):
<!-- prettier-ignore-start -->
```ts
import {
Fragment,
render as $$render,
createAstro as $$createAstro,
createComponent as $$createComponent,
renderComponent as $$renderComponent,
renderHead as $$renderHead,
maybeRenderHead as $$maybeRenderHead,
unescapeHTML as $$unescapeHTML,
renderSlot as $$renderSlot,
mergeSlots as $$mergeSlots,
addAttribute as $$addAttribute,
spreadAttributes as $$spreadAttributes,
defineStyleVars as $$defineStyleVars,
defineScriptVars as $$defineScriptVars,
renderTransition as $$renderTransition,
createTransitionScope as $$createTransitionScope,
renderScript as $$renderScript,
} from "astro/runtime/server/index.js";
import Foo from './Foo.astro';
import Bar from './Bar.astro';
const $$stdin = $$createComponent(($$result, $$props, $$slots) => {
return $$render`${$$maybeRenderHead($$result)}<div>
${$$renderComponent($$result,'Foo',Foo,{},{"default": () => $$render`
Domain Expansion
`,})}
${$$renderComponent($$result,'Bar',Bar,{"baz":"tizio"})}
</div>`;
}, '<stdin>', undefined);
export default $$stdin;
```
<!-- prettier-ignore-end -->
You can see how the `$$createComponent` function takes in the callback, which returns a few
template tags, essentially the rendered components.
## Intercepting the build process
When you install Domain Expansion and add the integration, it adds a Vite plugin. This plugin essentially
just wraps the `$$createComponent` function to add extra behavior before and after your component renders.
That extra behavior allows us to cache all information about each use of your component, such that, whenever
it is built again without any changes to the source code, props or slots, we just return the cached content.
The cache is saved in `node_modules/.domain-expansion`.
## What about assets?
Astro has built-in image optimization. That built-in image optimization adds the resulting asset to your build
output based on calls to the [`getImage` function](https://docs.astro.build/en/guides/images/#generating-images-with-getimage).
That function is also used in the [`<Image />`](https://docs.astro.build/en/guides/images/#display-optimized-images-with-the-image--component)
and [`<Picture />`](https://docs.astro.build/en/reference/modules/astro-assets/#picture-)
components. Domain Expansion detects when that function is called and also adds the parameters that the function
was called with to the cache. Whenever we reconstruct a component from the cache, we "replay" all calls to `getImage`
such that the image service is called just as if the component was rendered normally.
## Zero-cost on SSR
Astro builds the server code once for both prerendered and on-demand pages. The prerendered pages are generated
by running the same render code that you'll deploy to your server during build time with the requests for the
pages that should be prerendered. This means that if we simply transform Astro or your own code for bundling it
would also try to save and load caches on the server, adding a lot of code to your deployed bundle and severely
restricting your hosting platforms (by requiring both a Node.js runtime and a writable file-system).
Instead of that approach, Domain Expansion adds minimal code to your bundle. It adds one internal module that is
essentially just this:
```ts
export const domainExpansionComponents = globalThis[{{internal component symbol}}] ?? ((fn) => fn);
export const domainExpansionAssets = globalThis[{{internal assets symbol}}] ?? ((fn) => fn);
```
Then it modifies the definition of Astro's `createComponent` and `getImage` functions:
```ts ins={2-3,6} del={1,5}
function createComponent(...) {
import {domainExpansionComponents as $$domainExpansion} from '<internal module>';
const createComponent = $$domainExpansion(function createComponent(...) {
...
}
});
```
```ts ins={1} del={1,5}
export const getImage = async (...) => ...;
import {domainExpansionAssets as $$domainExpansion} from '<internal module>';
export const getImage = $$domainExpansion(async (...) => ...);
```
When your server is running, those wrappers will just return the original functions, so there is no change in behavior
for on-demand pages and the extra code shipped is just those 4 lines (2 definitions and 2 imports) and the wrapping.
During build, the render code runs in the same V8 isolate as the build process. This allows Domain Expansion to set a
different wrapper to be used only during build without shipping that code in the bundle.
### Bundling duplicates implementation
Astro has a bunch of classes and functions exported from `astro/runtime`. The runtime is bundled in the project by Vite.
This means that the instance used in the render code is not the same that an integration can import from `astro/runtime`,
it's the same code but in two modules so `value instanceof RuntimeClass` doesn't work since those are different, albeit
functionally identical, classes. We also need to reconstruct instances of those classes defined inside the bundle when
loading from cache, but again we can't import them.
To solve this problem, Domain Expansion also injects a little bit of extra code sending a reference to the runtime classes
back from the bundle into the build integration while bundle is loaded. The code looks like this:
```ts
import {someRuntimeFunction, SomeRuntimeClass} from 'astro/runtime/something';
Object.assign(
globalThis[<runtime transfer symbol>] ?? {},
{someRuntimeFunction, SomeRuntimeClass},
);
```
For this, in the Domain Expansion integration code, we add an empty object to the global scope under a private symbol
and it gets populated with the values from within the bundle.

View File

@ -1,41 +0,0 @@
---
title: Caveats
---
## Diminishing Returns
Let's get this out of the way first: Domain Expansion is not a silver bullet. This won't speed up every build process to below 10ms. In fact, in certain cases,
it might even slow down your build times due to the caching overhead. Here's the general rule of thumb:
1. If you have a fast CPU, you won't see significant improvements. We can only speed up the build process so much.
2. On small sites you won't see any improvements, and might even see a slowdown.
This extension is great for big projects and slow CPUs, but it's not a one-size-fits-all solution.
## Engines
Domain Expansion is _very_ particular about the build environment, and requires you to use Node.js. This is because the integration relies on functions that are only available in V8,
the runtime that Node.js uses.
If you use any runtime besides Node.js for building, Domain Expansion will not work. However, you are not required
to deploy to Node.js! After the build process is complete, any Astro-compatible runtime can be used to serve the
site.
## Global State
If you have components with global state, they might behave in weird ways, as Domain Expansion is unable to cache any
shared state between components, because every component is built in isolation. This means that when retrieving a
component from the cache, none if its side effects will be able to be recreated.
## Stale Content
Domain Expansion does not invalidate external content, recompute usage of random values, or datetime usage. The first time a component is rendered, it's output is cached until the cache is invalidated. For example, calling `Date.now()` in a component used in multiple locations will cause all locations to return the result of the first render of that component.
## Astro Versions
Domain Expansion relies on how two very specific functions are exported from Astro. Specifically where and how `$$createComponent`
and `getImage` are declared and exported from. If these functions are moved or changed in any way, Domain Expansion
will break. Since those functions are part of Astro's internal API, they can change at any time, even on patch releases.
If you find yourself having any issues with Domain Expansion, please open an issue on the
[GitHub repository](https://github.com/astro-expansion/domain-expansion).

View File

@ -1,38 +0,0 @@
---
title: Configuration
---
Domain Expansion can be configured to best suit your use-case. Here's all available options that can be passed to the integration and an explanation of what they do:
## `cachePages`
- Type: `boolean`
- Default: `true`
`cachePages` is the setting responsible for controlling if full pages will be cached. If you have shared state between pages based on a content collection, we recommend turning this off.
## `componentsHaveSharedState`
- Type: `boolean`
- Default: `false`
`componentsHaveSharedState` should be turned on when components rely on shared state, for example from `Astro.locals` or a common module instead of only props.
## `cacheComponents`
- Type: `false | 'in-memory' | 'persistent'`
- Default: `false`
`cacheComponents` determines how and whether components should be cached:
- `false`: Components will not be cached at all. For most projects, this will suffice, as pages will be cached by default.
- `'in-memory'`: Components will be cached in-memory during build and de-duplicated if they are used with the same props. This cache will be discarded after the build process finishes. In certain cases, this may speed up cold builds.
- `'persistent'`: Components will be cached in-memory and de-duplicated during the build process and written to disk afterwards. Only use this setting on small projects, as it will balloon the cache size massively.
## `cachePrefix`
- Type: `string`
- Default: `(empty string)`
- `@internal`
`cachePrefix` is an internal setting used for tests. If for some reason you want to have two caches for the same project, feel free to change this option to something other than an empty string!

View File

@ -1,32 +0,0 @@
---
title: Deploying
---
Here's guides on how to deploy Astro projects using Domain Expansion to different providers:
## Vercel
It just works.
## Netlify
It just works.
## Without Docker
It just works. *(Just retain the `node_modules/` directory as that is where the build cache lives)*
## Docker (Coolify etc.)
In Docker, we recommend using [cache mounts](https://docs.docker.com/build/cache/optimize/#use-cache-mounts). Here's how to use them within a `Dockerfile`:
```Dockerfile
RUN --mount=type=cache,target=/app/node_modules/.domain-expansion npm run build
```
Other than that, <br />
**It just works.**
<br />
<br />
<sub>Seeing a pattern here?</sub>

View File

@ -1,108 +0,0 @@
---
title: Domain Expansion
description: And they said incremental builds in Astro weren't possible.
---
import { Tabs, TabItem, Badge } from '@astrojs/starlight/components';
import BenchmarkResults from '../../components/astro/BenchmarkResults.astro';
Domain Expansion is an Astro integration that adds support for incremental builds. Basically,
builds go weee now.
## Benchmarks
Here's how Domain Expansion performs against the default Astro build, both in a cold and hot build scenario.
<BenchmarkResults />
You can read more about the benchmarks in the [benchmarks README](https://github.com/astro-expansion/domain-expansion/tree/main/benchmarks)
on our GitHub repository.
Basically - the bigger the site, the more you'll see Domain Expansion shine. You can read more about the
trade-offs on the [caveats page](/caveats).
## Installation
The Domain Expansion integration can be installed from npm using the following command:
<Tabs>
<TabItem label="npm">
```bash
npx astro add @domain-expansion/astro
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm astro add @domain-expansion/astro
```
</TabItem>
<TabItem label="yarn">
```bash
yarn astro add @domain-expansion/astro
```
</TabItem>
</Tabs>
This will install the integration and add it to your `astro.config.mjs`.
### Manual Installation
Install the `@domain-expansion/astro` package using your package manager of choice:
<Tabs>
<TabItem label="npm">
```bash
npm i @domain-expansion/astro
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm add @domain-expansion/astro
```
</TabItem>
<TabItem label="yarn">
```bash
yarn add @domain-expansion/astro
```
</TabItem>
</Tabs>
Import and use the integration in your `astro.config.mjs`:
```js
import { defineConfig } from 'astro/config';
import domainExpansion from '@domain-expansion/astro';
export default defineConfig({
integrations: [domainExpansion()],
});
```
## Usage
Once the integration has been included in your `astro.config.mjs`, the next time you build your site,
your build will be cached. From that point on, whenever you rebuild your site, only the files that have
changed will be rebuilt.
### When to use
Use this when you have big, and we mean **BIG** Astro sites. You'll see diminishing returns on smaller sites.
### Even more optimization
Pray that [Rolldown](https://rolldown.rs/) gets released soon. It's current version, Rollup,
accounts for about 90% of the build time for subsequent builds.
## Authors
- [Luiz Ferraz](https://github.com/Fryuni)
- [Louis Escher](https://github.com/louisescher)
- [Reuben Tier](https://github.com/theotterlord)

View File

@ -1,15 +0,0 @@
---
title: El funny
---
![Very Domain, Nice Expansion](../../assets/og.jpg)
![Another 3 Minutes to Rollup](../../assets/another-3-minutes-to-rollup.png)
![I am once again asking for faster build times](../../assets/bernie.png)
![They just reduced the Astro Docs build time below 4 minutes](../../assets/bush.png)
![The astro community drifting towards incremental builds](../../assets/car.png)
![Our incremental builds](../../assets/communism.png)
![What gives people feelings of power](../../assets/feelings-of-power.png)
![Astro incremental builds are now a thing](../../assets/goosebumps.png)
![Fryuni as the grim reaper](../../assets/grim-reaper.png)
![Astro community looking at incremental builds](../../assets/new-woman.png)
![Megamind "No incremental builds?"](../../assets/no-builds.png)

View File

@ -1,25 +0,0 @@
---
title: The Tale of the Three Mages
---
A long, long time ago, back when Astro didn't have incremental builds,
two mages met atop of a mountain to discuss their next mad idea.
"You ready to get started?", asked the first wizard. "Sorry, I need to
wait for a Netlify deployment to finish first. This documentation takes
ages to build.", replied the other. "It sure would be nice if there was a
way to speed that up. Incremental builds or something.", the first mage said.
It was at that moment that he realized: he knew a guy. The great mage,
spoken about in the myths and ancient legends, a master of the arcane knowledge.
They called the great mage and begun discussing. The ancient stone tablets reveal
some of the knowledge, although most of it looks like madness scribbled on an Excalidraw
board to those who cannot comprehend the arcane:
![Arcane Knowledge](../../assets/arcane-knowledge.png)
Legend has it that, at 12:30am, the mages begun conjuring their greatest spell so far.
Many had told them that whatever they were trying to do, they would fail, and that it
would be too difficult to conjure, the toll much too great. Nonetheless, the mages pushed
forward. After hours of blood, sweat and tears (of laughter™), their conjured masterpiece
laid before them. They decided to name it... Domain™ Expansion™.

View File

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -1,15 +0,0 @@
---
import type { Props } from '@astrojs/starlight/props';
import Default from '@astrojs/starlight/components/Head.astro';
// Get the URL of the generated image for the current page using its
// ID and replace the file extension with `.png`.
const ogImageUrl = '/og.jpg';
---
<!-- Render the default <Head/> component. -->
<Default {...Astro.props}><slot /></Default>
<!-- Render the <meta/> tags for the Open Graph images. -->
<meta property="og:image" content={ogImageUrl} />
<meta name="twitter:image" content={ogImageUrl} />

View File

@ -1,95 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
:root {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
body {
background-color: var(--sl-color-bg) !important;
color: var(--sl-color-text) !important;
}
path.recharts-rectangle.recharts-tooltip-cursor {
fill: var(--sl-color-gray-5) !important;
}
.recharts-tooltip {
border-radius: 4px;
border: 1px solid var(--sl-color-gray-5);
background-color: var(--sl-color-gray-6);
}
.recharts-tooltip > .grid > div > div > div {
position: relative;
bottom: 1px;
}
.recharts-tooltip > .grid > div > div:last-of-type {
gap: 0.5rem;
align-items: center;
}
.recharts-layer.recharts-bar-rectangle {
display: block;
margin-bottom: 0.25rem !important;
}
.recharts-rectangle.recharts-tooltip-cursor {
border-radius: 8px;
}
.recharts-layer > g:has(path[name='hot']) {
--color-duration: #94e2d5;
}
.recharts-layer > g:has(path[name='cold']) {
--color-duration: #74c7ec;
}
.recharts-layer > g:has(path[name='standard']) {
--color-duration: #b4befe;
}

View File

@ -1,59 +0,0 @@
import starlightPlugin from '@astrojs/starlight-tailwind';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
1: 'hsl(var(--chart-1))',
2: 'hsl(var(--chart-2))',
3: 'hsl(var(--chart-3))',
4: 'hsl(var(--chart-4))',
5: 'hsl(var(--chart-5))',
},
},
},
},
plugins: [require('tailwindcss-animate'), starlightPlugin()],
};

View File

@ -1,13 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "root",
"name": "@domain-expansion/astro",
"type": "module",
"private": true,
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
@ -26,15 +26,17 @@
"*.{js,ts,jsx,tsx,astro,json,md,mdx}": "prettier --write"
},
"devDependencies": {
"@changesets/cli": "^2.27.10",
"@changesets/cli": "^2.29.8",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"prettier": "^3.4.2",
"lint-staged": "^15.5.2",
"prettier": "^3.7.4",
"prettier-plugin-astro": "^0.14.1"
},
"pnpm": {
"patchedDependencies": {
"@astrojs/starlight": "patches/@astrojs__starlight.patch"
}
"patchedDependencies": {}
},
"dependencies": {
"tsup": "^8.5.1",
"typescript": "^5.9.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
export default {
"environment": "dev",
"environment": "build",
"isSsrBuild": false,
"projectBase": "",
"publicDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\public\\",
"rootDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\",
"mode": "dev",
"outDir": "dist",
"assetsDir": "/_astro",
"sourcemap": false,
"mode": "production",
"outDir": "C:\\Users\\zx\\Desktop\\polymech\\library.polymech\\dist\\",
"assetsDir": "_astro",
"sourcemap": "inline",
"assetFileNames": "/_astro/[name]@[width].[hash][extname]"
}

View File

@ -2,7 +2,7 @@
import fs from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { posix as path, resolve } from "node:path"
import { saveAndCopyAsset } from "./utils/saveAndCopyAsset.js"
import { saveAndCopyAsset, preCreateDirectories } from "./utils/saveAndCopyAsset.js"
import vitePluginAstroImageTools, { store } from "../plugin/index.js"
import pMap from "p-map"
import { sync as mkdir } from '@polymech/fs/dir'
@ -84,7 +84,15 @@ export default {
return;
}
// 5. Process the entire merged list of assets.
// 5. Pre-create all destination directories for faster processing
const assetPaths = [...allAssets.keys()];
console.time('[imagetools] Pre-create directories');
await preCreateDirectories(assetPaths, outDir, isSsrBuild);
console.timeEnd('[imagetools] Pre-create directories');
// 6. Process the entire merged list of assets.
// Using hardlinks now, so concurrency can be higher without Sharp issues
console.time('[imagetools] Copy/link assets');
await pMap(
[...allAssets.entries()],
async ([assetPath, { hash, image, buffer }]) => {
@ -102,11 +110,12 @@ export default {
logger.error(`Failed to process image ${assetPath}:`, error);
}
},
// higher concurrency causes sharp/vips errors as well
{ concurrency: 1 }
// Increased from 1: hardlinks are instant, and we skip sharp operations when possible
{ concurrency: 5 }
);
console.timeEnd('[imagetools] Copy/link assets');
// 6. Write the updated asset list back to the manifest for the next build.
// 7. Write the updated asset list back to the manifest for the next build.
try {
// Create a serializable version of the manifest data
const serializableAssets = [...allAssets.entries()].map(

View File

@ -2,8 +2,8 @@ import fs from "node:fs/promises";
import { posix as path } from "node:path";
import { fsCachePath } from "../../utils/runtimeChecks.js";
const copied = [];
let assetsDirExists;
const copied = new Set();
const dirsCreated = new Set();
export async function saveAndCopyAsset(
hash,
@ -14,33 +14,68 @@ export async function saveAndCopyAsset(
assetPath,
isSsrBuild
) {
// Use Set for O(1) lookup instead of O(n) array search
if (copied.has(assetPath)) return;
const src = fsCachePath + hash;
const dest = path.join(outDir, isSsrBuild ? "/client" : "", assetPath);
const destDir = path.dirname(dest);
assetsDir = path.join(outDir, isSsrBuild ? "/client" : "/", assetsDir);
if (copied.includes(assetPath)) return;
if (!assetsDirExists) {
await fs.mkdir(assetsDir, {
recursive: true,
});
assetsDirExists = true;
// Check if destination already exists and is valid (skip copy)
try {
const stats = await fs.stat(dest);
if (stats.size > 0) {
copied.add(assetPath);
console.log(`[skip] ${assetPath} (already exists)`);
return;
}
} catch {
// File doesn't exist, continue with copy
}
await fs.copyFile(src, dest).catch(async (error) => {
if (error.code === "ENOENT") {
const imageBuffer = buffer || (await image.toBuffer());
// Ensure destination directory exists (but only create once per unique dir)
if (!dirsCreated.has(destDir)) {
await fs.mkdir(destDir, { recursive: true });
dirsCreated.add(destDir);
}
await Promise.all(
[src, dest].map(async (dir) => {
await fs.writeFile(dir, imageBuffer);
})
);
} else throw error;
});
// Try hardlink first (instant, no disk usage), fallback to copy
let linked = false;
try {
await fs.link(src, dest);
linked = true;
} catch (linkError) {
// Hardlink failed, try copy
try {
await fs.copyFile(src, dest);
} catch (copyError) {
if (copyError.code === "ENOENT") {
// Source doesn't exist, regenerate from buffer/image
const imageBuffer = buffer || (await image.toBuffer());
await Promise.all([
fs.writeFile(src, imageBuffer),
fs.writeFile(dest, imageBuffer)
]);
console.log(`[regen] ${src} -> ${dest}`);
} else {
throw copyError;
}
}
}
copied.push(assetPath);
copied.add(assetPath);
}
// Helper to pre-create directories in batch (call before processing)
export async function preCreateDirectories(assetPaths, outDir, isSsrBuild) {
const uniqueDirs = new Set(
assetPaths.map(assetPath =>
path.dirname(path.join(outDir, isSsrBuild ? "/client" : "", assetPath))
)
);
await Promise.all(
[...uniqueDirs].map(dir => fs.mkdir(dir, { recursive: true }))
);
uniqueDirs.forEach(dir => dirsCreated.add(dir));
}