inject routes | deps

This commit is contained in:
babayaga 2025-08-25 08:22:26 +02:00
parent 48c77f446a
commit 4bdd60112a
201 changed files with 29917 additions and 86 deletions

View File

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@ -0,0 +1,14 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@domain-expansion-test/*",
"docs"
]
}

View File

@ -0,0 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dependencyDashboard": true,
"lockFileMaintenance": {
"enabled": true
},
"postUpdateOptions": ["pnpmDedupe"],
"packageRules": [
{
"groupName": "all dependencies",
"groupSlug": "all",
"matchPackagePatterns": ["*"],
"schedule": ["before 4am on Monday"],
"rangeStrategy": "bump"
}
],
"ignoreDeps": ["node"]
}

View File

@ -0,0 +1,53 @@
name: Surface PR Changesets
on: pull_request
permissions:
pull-requests: write
checks: write
statuses: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get changed files in the .changeset folder
id: changed-files
uses: tj-actions/changed-files@v35
with:
files: |
.changeset/**/*.md
- name: Check if any changesets contain minor or major changes
id: check
run: |
echo "Checking for changesets marked as minor or major"
echo "found=false" >> $GITHUB_OUTPUT
regex="[\"']astro[\"']: (minor|major)"
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
if [[ $(cat $file) =~ $regex ]]; then
version="${BASH_REMATCH[1]}"
echo "version=$version" >> $GITHUB_OUTPUT
echo "found=true" >> $GITHUB_OUTPUT
echo "$file has a $version release tag"
fi
done
- name: Add label
uses: actions/github-script@v6
if: steps.check.outputs.found == 'true'
env:
issue_number: ${{ github.event.number }}
with:
script: |
github.rest.issues.addLabels({
issue_number: process.env.issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['semver: ${{ steps.check.outputs.version }}']
});

View File

@ -0,0 +1,147 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
merge_group:
pull_request:
paths-ignore:
- "**/*.md"
- ".github/ISSUE_TEMPLATE/**"
# Automatically cancel older in-progress jobs on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
env:
FORCE_COLOR: true
ASTRO_TELEMETRY_DISABLED: true
# 7 GiB by default on GitHub, setting to 6 GiB
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
NODE_OPTIONS: --max-old-space-size=6144
jobs:
# Build primes out Turbo build cache and pnpm cache
build:
name: "Build - Node ${{ matrix.NODE_VERSION }}"
runs-on: ubuntu-latest
timeout-minutes: 3
strategy:
matrix:
NODE_VERSION: [20, 22]
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup node@${{ matrix.NODE_VERSION }}
uses: actions/setup-node@main
with:
node-version: ${{ matrix.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build Packages
run: pnpm run package:build
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 5
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Format Check
run: pnpm run lint
test:
name: "Test: Node ${{ matrix.NODE_VERSION }}"
runs-on: ubuntu-latest
timeout-minutes: 10
needs: build
strategy:
matrix:
NODE_VERSION: [20, 22]
fail-fast: false
env:
NODE_VERSION: ${{ matrix.NODE_VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-${{ matrix.NODE_VERSION }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ matrix.NODE_VERSION }}-turbo-
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup node@${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build Packages
run: pnpm run package:build
- name: Test
run: pnpm test
working-directory: package
duplicated-packages:
name: Check for duplicated dependencies
runs-on: ubuntu-latest
env:
NODE_VERSION: 22
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup node@${{ matrix.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Check duplicated dependencies
run: pnpm dedupe --prefer-offline --check

View File

@ -0,0 +1,50 @@
name: Preview mode
on:
pull_request:
types:
- synchronize
- opened
- reopened
env:
FORCE_COLOR: true
jobs:
no-preview:
name: Block Preview mode
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Check for preview mode
# Fails if in preview mode
run: pnpm changeset pre enter foo
- name: Remove Preview Label
uses: actions-ecosystem/action-remove-labels@v1
with:
labels: preview
- name: Add Label
if: ${{ failure() }}
uses: actions-ecosystem/action-add-labels@v1
with:
labels: preview

View File

@ -0,0 +1,68 @@
name: Release
on:
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize
- labeled
defaults:
run:
shell: bash
env:
FORCE_COLOR: true
jobs:
changelog:
name: Changelog PR or Release
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'preview') }}
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install
- name: Build Packages
run: pnpm run package:build
- name: Publish preview
if: ${{ contains(github.event.pull_request.labels.*.name, 'preview') }}
run: pnpm exec changeset publish
env:
# Use Node auth from above
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create Release Pull Request or Publish
id: changesets
if: ${{ github.event_name == 'push' }}
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: '[ci] release'
title: '[ci] release'
env:
GITHUB_TOKEN: ${{ secrets.COMMIT_TOKEN }}
# Needs access to publish to npm
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -0,0 +1,25 @@
name: TODO Tracking
on:
push:
# branches: [main]
permissions:
issues: read
repository-projects: read
contents: read
jobs:
track-todos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tdg-github-action
uses: ribtoks/tdg-github-action@master
with:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
DRY_RUN: false
COMMENT_ON_ISSUES: true

1
packages/domain-expansion/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,3 @@
#!/bin/sh
node_modules/.bin/lint-staged

View File

@ -0,0 +1,20 @@
### Copied directly from Astro's monorepo at:
### https://github.com/withastro/astro/blob/24663c9695385fed9ece57bf4aecdca3a8581e70/.prettierignore
# Deep Directories
**/dist
**/smoke
**/node_modules
**/fixtures
**/vendor
**/.vercel
# Directories
.github
.changeset
*.hbs
# Files
pnpm-lock.yaml
flake.lock

View File

@ -0,0 +1,4 @@
{
"editor.defaultFormatter": "prettier",
"editor.gotoLocation.multipleDefinitions": "goto"
}

View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
report@lferraz.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@ -0,0 +1,31 @@
FROM node:lts
WORKDIR /app
COPY package.json /app/package.json
COPY pnpm-lock.yaml /app/pnpm-lock.yaml
COPY pnpm-workspace.yaml /app/pnpm-workspace.yaml
COPY ./patches /app/patches
COPY ./package /app/package
COPY ./docs /app/docs
RUN corepack enable
RUN pnpm install
WORKDIR /app/package
RUN pnpm build
WORKDIR /app/docs
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make gcc g++ \
&& rm -rf /var/lib/apt/lists/*
RUN pnpm build
EXPOSE 4321:4321
CMD ["pnpm", "start"]

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 astro-expansion
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,25 @@
# DOMAIN :tm: EXPANSION :tm:
Incremental :tm: build :tm: for :tm: Astro (not our :tm:).
## 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:
![Pasted image 20241213003811](https://github.com/user-attachments/assets/b6e25ca2-c8e2-493c-a651-1cb51eef3b65)
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™.**
## Licensing
[MIT Licensed](./LICENSE). Made with ❤️ :tm: and :joy: by [the :tm: Domain :tm: Expansion :tm: members :tm:](https://github.com/orgs/astro-expansion/people).
![deepfried_1734047259481](https://github.com/user-attachments/assets/6a5e55da-833f-4a5a-ba00-474c0871446b)

View File

@ -0,0 +1,2 @@
*/
!.results/

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :------------------------------------------- | ---------------: | ------: | ------: | ----------: |
| `[Astro Docs] Normal Build` | 263.283 ± 13.774 | 251.574 | 278.459 | 1.84 ± 0.11 |
| `[Astro Docs] Domain Expansion (cold build)` | 272.885 ± 15.867 | 256.185 | 287.762 | 1.91 ± 0.12 |
| `[Astro Docs] Domain Expansion (hot build)` | 143.194 ± 4.299 | 139.882 | 148.052 | 1.00 |

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :-------------------------------------------- | -------------: | ------: | ------: | ----------: |
| `[astro.build] Normal Build` | 30.852 ± 3.303 | 27.322 | 37.362 | 1.01 ± 0.12 |
| `[astro.build] Domain Expansion (cold build)` | 31.298 ± 2.398 | 26.875 | 34.678 | 1.02 ± 0.09 |
| `[astro.build] Domain Expansion (hot build)` | 30.660 ± 1.442 | 28.201 | 33.215 | 1.00 |

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :--------------------------------------------- | ------------: | ------: | ------: | ----------: |
| `[Brutal Theme] Normal Build` | 4.491 ± 0.312 | 3.972 | 5.214 | 1.00 |
| `[Brutal Theme] Domain Expansion (cold build)` | 4.638 ± 0.298 | 4.402 | 5.188 | 1.03 ± 0.10 |
| `[Brutal Theme] Domain Expansion (hot build)` | 4.492 ± 0.266 | 4.122 | 5.059 | 1.00 ± 0.09 |

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :----------------------------------------------- | -------------: | ------: | ------: | ----------: |
| `[Starlight Docs] Normal Build` | 34.021 ± 2.990 | 30.565 | 39.803 | 1.14 ± 0.15 |
| `[Starlight Docs] Domain Expansion (cold build)` | 35.049 ± 1.906 | 32.662 | 37.278 | 1.18 ± 0.14 |
| `[Starlight Docs] Domain Expansion (hot build)` | 29.722 ± 3.016 | 26.496 | 34.582 | 1.00 |

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :-------------------------------------------------- | -------------: | ------: | ------: | ----------: |
| `[StudioCMS UI Docs] Normal Build` | 10.523 ± 0.455 | 9.856 | 11.113 | 1.07 ± 0.07 |
| `[StudioCMS UI Docs] Domain Expansion (cold build)` | 10.721 ± 0.472 | 10.239 | 11.412 | 1.09 ± 0.07 |
| `[StudioCMS UI Docs] Domain Expansion (hot build)` | 9.826 ± 0.462 | 9.315 | 10.489 | 1.00 |

View File

@ -0,0 +1,5 @@
| Command | Mean [s] | Min [s] | Max [s] | Relative |
| :---------------------------------------------------- | -------------: | ------: | ------: | ----------: |
| `[Zen Browser Website] Normal Build` | 14.983 ± 0.631 | 14.132 | 15.977 | 1.00 |
| `[Zen Browser Website] Domain Expansion (cold build)` | 17.880 ± 0.603 | 16.973 | 18.934 | 1.19 ± 0.06 |
| `[Zen Browser Website] Domain Expansion (hot build)` | 17.490 ± 0.469 | 16.870 | 18.533 | 1.17 ± 0.06 |

View File

@ -0,0 +1,47 @@
# Benchmarks
A benchmark using [`hyperfine`](https://github.com/sharkdp/hyperfine?tab=readme-ov-file). Used for the data we display on [domainexpansion.gg](https://domainexpansion.gg).
## About the results
The current results you find in `.results/` were created on Dec. 26, 2024 on a desktop PC with the following specs:
```
CPU: AMD Ryzen 9 5950X (32) @ 5,05 GHz
RAM: 32GB DDR4 3200MHz
(Storage: PCIE Gen.4 NVME SSD)
```
## Getting Started
First, install [`hyperfine`](https://github.com/sharkdp/hyperfine?tab=readme-ov-file). You can find instructions in their README file. To run this benchmark, you also need to have `git` and Python installed.
Once installed, run the `bench.sh` script in this directory.
### Running specific benchmarks
You can run the script with the `--exclude=...` flag to exclude certain benchmarks. The following values are valid to pass in:
- `astro-docs`
- `starlight`
- `astro.build`
- `studiocms-ui`
- `brutal`
- `zen-browser`
For example, using `./bench.sh --exlude=astro-docs,astro.build` would exclude the two longest benchmarks!
## What we benchmark
We chose 6 open-source Astro projects of varying sizes:
1. [astro.build](https://astro.build), the official Astro website
2. [docs.astro.build](https://docs.astro.build), the Astro docs and probably the biggest Astro-powered repository out there due to its translations
3. [starlight.astro.build](https://starlight.astro.build), the documentation for Starlight to represent mid-scale documentation projects
4. [ui.studiocms.dev](https://ui.studiocms.dev), a small documentation with a lot of MDX components
5. [zen-browser.app](https://zen-browser.app), a small landing page
6. [brutal.elian.codes](https://brutal.elian.codes), a popular Astro theme and rather small project compared to the rest
## How we benchmark
First, all repositories are cloned. Afterwards, we go into the directory and run `astro build` once to populate the asset cache. Afterwards, we run `astro build` 10 times, which is `hyperfine`'s default. Once that is done, we add the `@domain-expansion/astro` integration and run `astro build` another 10 times, each time making sure we remove the cache that is created. Last but certainly not least, we run `astro build` without removing the cache first, yet another 10 times. After all benchmarks have concluded, we move on to the next repository.

View File

@ -0,0 +1,331 @@
#! /usr/bin/bash
###############################
# Setup
###############################
EXCLUED_BENCHMARKS=""
while [[ $# -gt 0 ]]; do
case $1 in
--exclude=*)
excluded_list="${1#*=}" # Extract the value after '='
shift
;;
*)
echo "Unknown argument: $1"
exit 1
;;
esac
done
IFS=',' read -ra excluded_array <<< "$excluded_list"
is_excluded() {
local item=$1
for excluded in "${excluded_array[@]}"; do
if [[ $excluded == "$item" ]]; then
return 0 # Item is in the list
fi
done
return 1 # Item is not in the list
}
# Create .results dir if it doesn't exits already
if [ ! -d ".results" ]; then
mkdir .results
fi
ROOT=$PWD
# Colors
NO_FORMAT="\033[0m"
F_BOLD="\033[1m"
C_MEDIUMPURPLE1="\033[38;5;141m"
C_MEDIUMSPRINGGREEN="\033[38;5;49m"
C_RED="\033[38;5;9m"
F_UNDERLINED="\033[4m"
C_STEELBLUE1="\033[38;5;75m"
echo -e "${F_BOLD}${C_MEDIUMPURPLE1}\n[Benchmark Setup]${NO_FORMAT}"
temp_dir=$(mktemp -d)
if [ ! -d "$temp_dir" ]; then
echo -e "\n${C_RED}[ERROR]${NO_FORMAT} Failed to create temp directory! Aborting...\n"
exit 1
fi
echo -e " ${F_BOLD}New temporary diretory created at:\n ${C_STEELBLUE1}$temp_dir${NO_FORMAT}\n"
echo -e " ${F_BOLD}Cloning benchmark repositories...${NO_FORMAT}"
cd "$temp_dir"
if ! is_excluded "astro-docs"; then
{
git clone --depth 1 https://github.com/withastro/docs &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}withastro/docs${NO_FORMAT}!"
} &
fi
if ! is_excluded "zen-browser"; then
{
git clone --depth 1 https://github.com/zen-browser/www zen-browser &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}zen-browser/www${NO_FORMAT}!"
} &
fi
if ! is_excluded "studiocms-ui"; then
{
git clone --depth 1 https://github.com/withstudiocms/ui &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}withstudiocms/ui${NO_FORMAT}!"
} &
fi
if ! is_excluded "brutal"; then
{
git clone --depth 1 https://github.com/eliancodes/brutal &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}eliancodes/brutal${NO_FORMAT}!"
} &
fi
if ! is_excluded "starlight"; then
{
git clone --depth 1 https://github.com/withastro/starlight &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}withastro/starlight${NO_FORMAT}!"
} &
fi
if ! is_excluded "astro.build"; then
{
git clone --depth 1 https://github.com/withastro/astro.build &> /dev/null
echo -e " Cloned ${C_STEELBLUE1}withastro/astro.build${NO_FORMAT}!"
} &
fi
wait
###############################
# zen-browser/www
###############################
if ! is_excluded "zen-browser"; then
cd "$temp_dir/zen-browser"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}zen-browser/www${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
yes | pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm build${NO_FORMAT} (to warm up)..."
pnpm build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/zen-browser.md" \
--prepare '' \
-n '[Zen Browser Website] Normal Build' \
'pnpm build' \
--prepare 'pnpm astro add @domain-expansion/astro -y && rm -rf ./node_modules/.domain-expansion' \
-n '[Zen Browser Website] Domain Expansion (cold build)' \
'pnpm build' \
--prepare '' \
-n '[Zen Browser Website] Domain Expansion (hot build)' \
'pnpm build'
fi
###############################
# withstudiocms/ui
###############################
if ! is_excluded "studiocms-ui"; then
cd "$temp_dir/ui/docs"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}withstudiocms/ui${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
yes | pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm build${NO_FORMAT} (to warm up)..."
pnpm build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/studiocms-ui.md" \
--prepare '' \
-n '[StudioCMS UI Docs] Normal Build' \
'pnpm astro build' \
--prepare 'pnpm astro add @domain-expansion/astro -y && rm -rf ./node_modules/.domain-expansion' \
-n '[StudioCMS UI Docs] Domain Expansion (cold build)' \
'pnpm astro build' \
--prepare '' \
-n '[StudioCMS UI Docs] Domain Expansion (hot build)' \
'pnpm astro build'
fi
###############################
# eliancodes/brutal
###############################
if ! is_excluded "brutal"; then
cd "$temp_dir/brutal"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}eliancodes/brutal${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
yes | pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm build${NO_FORMAT} (to warm up)..."
pnpm build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/brutal.md" \
-n '[Brutal Theme] Normal Build' \
--prepare '' \
'pnpm build' \
--prepare 'pnpm astro add @domain-expansion/astro -y && rm -rf ./node_modules/.domain-expansion' \
-n '[Brutal Theme] Domain Expansion (cold build)' \
'pnpm build' \
--prepare '' \
-n '[Brutal Theme] Domain Expansion (hot build)' \
'pnpm build'
fi
###############################
# withastro/starlight
###############################
if ! is_excluded "starlight"; then
cd "$temp_dir/starlight/docs"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}withastro/starlight${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
yes | pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm build${NO_FORMAT} (to warm up)..."
pnpm build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/starlight.md" \
--prepare '' \
-n '[Starlight Docs] Normal Build' \
'pnpm build' \
--prepare 'pnpm astro add @domain-expansion/astro -y && rm -rf ./node_modules/.domain-expansion' \
-n '[Starlight Docs] Domain Expansion (cold build)' \
'pnpm build' \
--prepare '' \
-n '[Starlight Docs] Domain Expansion (hot build)' \
'pnpm build'
fi
###############################
# withastro/astro.build
###############################
if ! is_excluded "astro.build"; then
cd "$temp_dir/astro.build"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}withastro/astro.build${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm astro build${NO_FORMAT} (to warm up)..."
pnpm astro build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/astro.build.md" \
--prepare '' \
-n '[astro.build] Normal Build' \
'pnpm astro build' \
--prepare 'npx astro add @domain-expansion/astro && rm -rf ./node_modules/.domain-expansion' \
-n '[astro.build] Domain Expansion (cold build)' \
'pnpm astro build' \
--prepare '' \
-n '[astro.build] Domain Expansion (hot build)' \
'pnpm astro build'
fi
###############################
# withastro/docs
###############################
if ! is_excluded "astro-docs"; then
cd "$temp_dir/docs"
echo -e "\n${F_BOLD}Running Setup for ${C_STEELBLUE1}withastro/docs${NO_FORMAT}${F_BOLD}...${NO_FORMAT}"
export NODE_OPTIONS=--max-old-space-size=12192 SKIP_OG=true;
echo -e " Running ${C_STEELBLUE1}pnpm install${NO_FORMAT}..."
yes | pnpm install &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}"
echo -e " Running ${C_STEELBLUE1}pnpm build${NO_FORMAT} (to warm up)..."
pnpm build &> /dev/null
echo -e " ${C_MEDIUMSPRINGGREEN}Done!${NO_FORMAT}\n"
hyperfine \
--export-markdown "$ROOT/.results/astro-docs.md" \
--runs 3 \
--prepare '' \
-n '[Astro Docs] Normal Build' \
'pnpm build' \
--prepare 'pnpm astro add @domain-expansion/astro -y && rm -rf ./node_modules/.domain-expansion' \
-n '[Astro Docs] Domain Expansion (cold build)' \
'pnpm build' \
--prepare '' \
-n '[Astro Docs] Domain Expansion (hot build)' \
'pnpm build'
fi
###############################
# Cleanup
###############################
cd "$temp_dir"
# Calculate all folder sizes and print
echo -e "\n${F_BOLD}${C_MEDIUMPURPLE1}[Cache Size Summary]${NO_FORMAT}"
if ! is_excluded "zen-browser"; then
echo -e "${F_BOLD}${C_STEELBLUE1}zen-browser/www${NO_FORMAT}: $(du -sh zen-browser/node_modules/.domain-expansion | cut -f1)"
fi
if ! is_excluded "studiocms-ui"; then
echo -e "${F_BOLD}${C_STEELBLUE1}withstudiocms/ui${NO_FORMAT}: $(du -sh ui/docs/node_modules/.domain-expansion | cut -f1)"
fi
if ! is_excluded "brutal"; then
echo -e "${F_BOLD}${C_STEELBLUE1}eliancodes/brutal${NO_FORMAT}: $(du -sh brutal/node_modules/.domain-expansion | cut -f1)"
fi
if ! is_excluded "starlight"; then
echo -e "${F_BOLD}${C_STEELBLUE1}withastro/starlight${NO_FORMAT}: $(du -sh starlight/docs/node_modules/.domain-expansion | cut -f1)"
fi
if ! is_excluded "astro.build"; then
echo -e "${F_BOLD}${C_STEELBLUE1}withastro/astro.build${NO_FORMAT}: $(du -sh 'astro.build/node_modules/.domain-expansion' | cut -f1)"
fi
if ! is_excluded "astro-docs"; then
echo -e "${F_BOLD}${C_STEELBLUE1}withastro/docs${NO_FORMAT}: $(du -sh docs/node_modules/.domain-expansion | cut -f1)"
fi
cd "$ROOT"
rm -rf "$temp_dir"
echo ""
echo "$temp_dir deleted"
echo ""

View File

@ -0,0 +1,21 @@
# 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

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

View File

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

View File

@ -0,0 +1,59 @@
# 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

@ -0,0 +1,54 @@
# 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

@ -0,0 +1,52 @@
// @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

@ -0,0 +1,21 @@
{
"$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

@ -0,0 +1,37 @@
{
"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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,116 @@
---
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

@ -0,0 +1,98 @@
'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

@ -0,0 +1,55 @@
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

@ -0,0 +1,327 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,128 @@
[
{
"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

@ -0,0 +1,137 @@
---
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

@ -0,0 +1,41 @@
---
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

@ -0,0 +1,38 @@
---
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

@ -0,0 +1,32 @@
---
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

@ -0,0 +1,108 @@
---
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

@ -0,0 +1,15 @@
---
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

@ -0,0 +1,25 @@
---
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

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

View File

@ -0,0 +1,15 @@
---
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

@ -0,0 +1,95 @@
@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

@ -0,0 +1,59 @@
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

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

2010
packages/domain-expansion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
{
"name": "root",
"type": "module",
"private": true,
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
"engines": {
"node": ">=18.20.3"
},
"scripts": {
"package:build": "pnpm -r --filter @domain-expansion/astro build",
"package:dev": "pnpm -r --filter @domain-expansion/astro dev",
"playground:dev": "pnpm --filter playground dev",
"playground:build": "pnpm --filter playground build",
"docs:dev": "pnpm --filter docs dev",
"docs:build": "pnpm --filter docs build",
"docs:start": "pnpm --filter docs start",
"dev": "pnpm --stream -r -parallel dev",
"changeset": "changeset",
"release": "node scripts/release.mjs",
"lint": "prettier -c \"**/*\" --ignore-unknown --cache",
"lint:fix": "prettier -w \"**/*\" --ignore-unknown --cache",
"version": "changeset version && pnpm install && pnpm lint:fix",
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,astro,json,md,mdx}": "prettier --write"
},
"devDependencies": {
"@changesets/cli": "^2.27.10",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1"
},
"pnpm": {
"patchedDependencies": {
"@astrojs/starlight": "patches/@astrojs__starlight.patch"
}
}
}

View File

@ -0,0 +1 @@
dist

View File

@ -0,0 +1,87 @@
# @domain-expansion/astro
## 1.0.0
### Major Changes
- 677ab1d: Astro Domain Expanded successfully
### Minor Changes
- a706890: Change bundle config
- a706890: Sync versions
- a706890: Cache invalidation
- a706890: Add configuration for components using shared state
- a706890: Fix bundling unused dependencies
- a706890: Make cache configurable
- a706890: Initial beta release
- a706890: Change in-memory cache to an LRU
- a706890: Eagerly normalize loaded chunks
- a706890: Memory and performance improvements
- a706890: Make statefull components configurable through env vars
### Patch Changes
- a706890: Fix env var name
## 0.1.0-beta.9
### Patch Changes
- Fix env var name
## 0.1.0-beta.8
### Minor Changes
- Make statefull components configurable through env vars
## 0.1.0-beta.7
### Minor Changes
- cf9e4ea: Eagerly normalize loaded chunks
## 0.1.0-beta.6
### Minor Changes
- 8684bcb: Add configuration for components using shared state
## 0.1.0-beta.5
### Minor Changes
- 4309f3e: Memory and performance improvements
## 0.1.0-beta.4
### Minor Changes
- 1278f19: Fix bundling unused dependencies
## 0.1.0-beta.3
### Minor Changes
- Change bundle config
- 641dfce: Make cache configurable
- 7f93ad2: Change in-memory cache to an LRU
## 0.1.0-beta.2
### Minor Changes
- Sync versions
## 0.1.0-beta.1
### Minor Changes
- 095445d: Cache invalidation
## 0.1.0-beta.0
### Minor Changes
- 76702d0: Initial beta release

View File

@ -0,0 +1,83 @@
# `@domain-expansion/astro`
This is an [Astro integration](https://docs.astro.build/en/guides/integrations-guide/) that Expands the Domain!
_It adds Incremental Builds to Astro projects._
## Usage
### Installation
Install the integration **automatically** using the Astro CLI:
```bash
pnpm astro add @domain-expansion/astro
```
```bash
npx astro add @domain-expansion/astro
```
```bash
yarn astro add @domain-expansion/astro
```
Or install it **manually**:
1. Install the required dependencies
```bash
pnpm add @domain-expansion/astro
```
```bash
npm install @domain-expansion/astro
```
```bash
yarn add @domain-expansion/astro
```
2. Add the integration to your astro config
```diff
+import domainExpansion from "@domain-expansion/astro";
export default defineConfig({
integrations: [
+ domainExpansion(),
],
});
```
## Contributing
This package is structured as a monorepo:
- `playground` contains code for testing the package
- `package` contains the actual package
- `docs` contains the documentation
Install dependencies using pnpm:
```bash
pnpm i --frozen-lockfile
```
Start the playground and package watcher:
```bash
pnpm playground:dev
```
You can now edit files in `package`. Please note that making changes to those files may require restarting the playground dev server.
## Licensing
[MIT Licensed](https://github.com/astro-expansion/domain-expansion/blob/main/LICENSE). Made with ❤️ by [the Domain Expansion](https://domainexpansion.gg).
## Acknowledgements
- [Luiz Ferraz](https://github.com/Fryuni)
- [Louis Escher](https://github.com/louisescher)
- [Reuben Tier](https://github.com/theotterlord)

View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@ -0,0 +1,67 @@
{
"name": "@domain-expansion/astro",
"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": "tsup --watch",
"build": "tsup",
"test": "vitest run --coverage",
"test:dev": "vitest",
"test:debug": "vitest run --inspect --no-file-parallelism --testTimeout 1000000"
},
"type": "module",
"peerDependencies": {
"astro": "catalog:"
},
"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-integration-kit": "^0.17.0",
"jest-extended": "^4.0.2",
"rollup": "^4.29.0",
"tsup": "^8.3.5",
"vite": "^6.0.3",
"vitest": "^2.1.8"
}
}

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;
}
}

View File

@ -0,0 +1,163 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { PersistedMetadata } from './renderFileStore.js';
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('Wrapping getImage');
return (runtime.getImage = async (options) => {
const result = await original(options);
const context = contextTracking.getStore();
if (context) {
const val: PersistedMetadata['assetServiceCalls'][number] = {
options: result.rawOptions,
resultingAttributes: result.attributes,
};
debug('Collected getImage call', val);
context.assetServiceCalls.push(val);
}
return result;
});
};
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;
});
};

View File

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

View File

@ -0,0 +1,78 @@
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 } 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);
}
return makeRenderTemplateClone(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;
}
}

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();
}
}

View File

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

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'])}
`);
},
},
},
});
},
},
};
},
});

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';
}

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;
}
};

View File

@ -0,0 +1,274 @@
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
: resultValue.content;
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) {
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;
}
}
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;
};
}

View File

@ -0,0 +1,491 @@
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;
headInTree: boolean;
extraHead: 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
),
},
};
} 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 };
}
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
);
}
}

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,
},
},
],
});

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,
}),
],
});

Some files were not shown because too many files have changed in this diff Show More