diff options
author | Luiz Ferraz | 2024-09-06 19:38:30 -0300 |
---|---|---|
committer | GitHub | 2024-09-06 19:38:30 -0300 |
commit | 6f3202b3eb747de8a1cfcba001ab618d5fdee44a (patch) | |
tree | ca4988028b6a7b8fa5ec4f30e0197b4db199c37e | |
parent | 20cbf3b6a4d1598a62fdb176ebaa849bc7b978f7 (diff) | |
download | IT.starlight-6f3202b3eb747de8a1cfcba001ab618d5fdee44a.tar.gz IT.starlight-6f3202b3eb747de8a1cfcba001ab618d5fdee44a.tar.bz2 IT.starlight-6f3202b3eb747de8a1cfcba001ab618d5fdee44a.zip |
Add support for SSR (#1255)
Co-authored-by: Patrick Jakubik <37963339+patryk-smc@users.noreply.github.com>
Co-authored-by: Roni Costa <622159+ronildo@users.noreply.github.com>
Co-authored-by: Ryan Russell <523300+ryanrussell@users.noreply.github.com>
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Co-authored-by: Murali Manohar Varma <67296473+mmv-dev@users.noreply.github.com>
Co-authored-by: Marcelo Cardoso <33183880+marcelovicentegc@users.noreply.github.com>
Co-authored-by: Aaron Siddhartha Mondal <28633256+aaronmondal@users.noreply.github.com>
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
48 files changed, 1004 insertions, 146 deletions
diff --git a/.changeset/ninety-singers-film.md b/.changeset/ninety-singers-film.md new file mode 100644 index 00000000..3b8fa285 --- /dev/null +++ b/.changeset/ninety-singers-film.md @@ -0,0 +1,7 @@ +--- +'@astrojs/starlight': patch +--- + +Improves performance of computing the last updated times from Git history. + +Instead of executing `git` for each docs page, it is now executed twice regardless of the number of pages. diff --git a/.changeset/quiet-penguins-wonder.md b/.changeset/quiet-penguins-wonder.md new file mode 100644 index 00000000..5fda6c0f --- /dev/null +++ b/.changeset/quiet-penguins-wonder.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Fixes last updated times on projects with custom `srcDir` diff --git a/.changeset/six-phones-boil.md b/.changeset/six-phones-boil.md new file mode 100644 index 00000000..fa22cbed --- /dev/null +++ b/.changeset/six-phones-boil.md @@ -0,0 +1,16 @@ +--- +'@astrojs/starlight': minor +--- + +Adds support for server-rendered Starlight pages. + +When building a project with `hybrid` or `server` output mode, a new `prerender` option on Starlight config can be set to `false` to make all Starlight pages be rendered on-demand: + +```ts +export default defineConfig({ + output: 'server', + integrations: [starlight({ + prerender: false + })], +}) +``` diff --git a/docs/src/content/docs/manual-setup.mdx b/docs/src/content/docs/manual-setup.mdx index 0d7d5092..07465d9f 100644 --- a/docs/src/content/docs/manual-setup.mdx +++ b/docs/src/content/docs/manual-setup.mdx @@ -125,6 +125,6 @@ In the future, we plan to support this use case better to avoid the need for the ### Use Starlight with SSR -You can use Starlight alongside custom on-demand rendered pages in your project by following the [“On-demand Rendering Adapters”](https://docs.astro.build/en/guides/server-side-rendering/) guide in Astro’s docs. +To enable SSR, follow the [“On-demand Rendering Adapters”](https://docs.astro.build/en/guides/server-side-rendering/) guide in Astro’s docs to add a server adapter to your Starlight project. -Currently, documentation pages generated by Starlight are always prerendered regardless of your project's output mode. We hope to be able to support on-demand rendering for Starlight pages soon. +Documentation pages generated by Starlight are pre-rendered by default regardless of your project's output mode. To opt out of pre-rendering your Starlight pages, set the [`prerender` config option](/reference/configuration/#prerender) to `false`. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 8b97b0d3..c878141b 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -455,6 +455,18 @@ Define whether Starlight’s default site search provider [Pagefind](https://pag Set to `false` to disable indexing your site with Pagefind. This will also hide the default search UI if in use. +Pagefind cannot be enabled when the [`prerender`](#prerender) option is set to `false`. + +### `prerender` + +**type:** `boolean` +**default:** `true` + +Define whether Starlight pages should be pre-rendered to static HTML or on-demand rendered by an [SSR adapter](https://docs.astro.build/en/guides/server-side-rendering/). + +Starlight pages are pre-rendered by default. +If you are using an SSR adapter and want to render Starlight pages on demand, set `prerender: false`. + ### `head` **type:** [`HeadConfig[]`](#headconfig) diff --git a/packages/starlight/.gitignore b/packages/starlight/.gitignore index 071d33cb..004798ee 100644 --- a/packages/starlight/.gitignore +++ b/packages/starlight/.gitignore @@ -1,4 +1,4 @@ # Astro generates this during tests, but we want to ignore it. -src/env.d.ts -__tests__/**/env.d.ts +env.d.ts __tests__/**/types.d.ts +.astro diff --git a/packages/starlight/__e2e__/collection-config.test.ts b/packages/starlight/__e2e__/collection-config.test.ts index 7e9d6d06..feeadfe3 100644 --- a/packages/starlight/__e2e__/collection-config.test.ts +++ b/packages/starlight/__e2e__/collection-config.test.ts @@ -2,12 +2,13 @@ import { expect, testFactory } from './test-utils'; // This fixture contains a space in the directory so that we have a smoke test for building // Starlight projects with pathnames like this, which are a common source of bugs. -const test = await testFactory('./fixtures/custom src-dir/'); +const test = testFactory('./fixtures/custom src-dir/'); test('builds a custom page using the `<StarlightPage>` component and a custom `srcDir`', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/custom'); await expect(page.getByText('Hello')).toBeVisible(); diff --git a/packages/starlight/__e2e__/fixtures/git/.gitignore b/packages/starlight/__e2e__/fixtures/git/.gitignore new file mode 100644 index 00000000..1fb22bd5 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/git/.gitignore @@ -0,0 +1,10 @@ +# This fixture is used for the E2E tests related to Git where +# a new repo is created. Nested repos do not inherit the .gitignore +# options from the parent repo, so even though these files are +# already ignored on the package they have to be re-declared here. +# Do not delete this file or the Git tests will try to index and +# and commit all the node_modules and generated files from the test. +env.d.ts +.astro +/node_modules/ +/dist/ diff --git a/packages/starlight/__e2e__/fixtures/git/astro.config.mjs b/packages/starlight/__e2e__/fixtures/git/astro.config.mjs new file mode 100644 index 00000000..0631c4f0 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/git/astro.config.mjs @@ -0,0 +1,11 @@ +import starlight from '@astrojs/starlight'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [ + starlight({ + title: 'Git', + pagefind: false, + }), + ], +}); diff --git a/packages/starlight/__e2e__/fixtures/git/package.json b/packages/starlight/__e2e__/fixtures/git/package.json new file mode 100644 index 00000000..b120c72d --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/git/package.json @@ -0,0 +1,9 @@ +{ + "name": "@e2e/git", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/starlight": "workspace:*", + "astro": "^4.15.3" + } +} diff --git a/packages/starlight/__e2e__/fixtures/git/src/content/config.ts b/packages/starlight/__e2e__/fixtures/git/src/content/config.ts new file mode 100644 index 00000000..45f60b01 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/git/src/content/config.ts @@ -0,0 +1,6 @@ +import { defineCollection } from 'astro:content'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ schema: docsSchema() }), +}; diff --git a/packages/starlight/__e2e__/fixtures/git/src/content/docs/index.md b/packages/starlight/__e2e__/fixtures/git/src/content/docs/index.md new file mode 100644 index 00000000..9a02f87c --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/git/src/content/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Home Page +lastUpdated: true +--- + +Home page content diff --git a/packages/starlight/__e2e__/fixtures/ssr/astro.config.mjs b/packages/starlight/__e2e__/fixtures/ssr/astro.config.mjs new file mode 100644 index 00000000..3faa2cd8 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/astro.config.mjs @@ -0,0 +1,25 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import node from '@astrojs/node'; + +const prerendering = process.env.STARLIGHT_PRERENDER === 'yes'; + +export default defineConfig({ + output: 'server', + adapter: node({ mode: 'standalone' }), + compressHTML: false, // for easier debugging + // Output to different folders and expose on different ports + // on each case so the servers don't conflict with + // each other during tests. + outDir: prerendering ? 'dist' : 'build', + server: { + port: prerendering ? 4322 : 4321, + }, + integrations: [ + starlight({ + title: 'SSR', + prerender: prerendering, + pagefind: false, + }), + ], +}); diff --git a/packages/starlight/__e2e__/fixtures/ssr/package.json b/packages/starlight/__e2e__/fixtures/ssr/package.json new file mode 100644 index 00000000..da2b31f8 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/package.json @@ -0,0 +1,10 @@ +{ + "name": "@e2e/ssr", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/node": "^8.3.2", + "@astrojs/starlight": "workspace:*", + "astro": "^4.15.3" + } +} diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/component/ServerCheck.astro b/packages/starlight/__e2e__/fixtures/ssr/src/component/ServerCheck.astro new file mode 100644 index 00000000..f6450a75 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/component/ServerCheck.astro @@ -0,0 +1,15 @@ +--- +function checkServer() { + try { + Astro.clientAddress; + + return true; + } catch { + // Accessing `clientAddress` during build time + // fails, so it is not SSR. + return false; + } +} +--- + +<div id="server-check">{checkServer() ? 'On server' : 'Not server'}</div> diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/content/config.ts b/packages/starlight/__e2e__/fixtures/ssr/src/content/config.ts new file mode 100644 index 00000000..45f60b01 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/content/config.ts @@ -0,0 +1,6 @@ +import { defineCollection } from 'astro:content'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ schema: docsSchema() }), +}; diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/404.mdx b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/404.mdx new file mode 100644 index 00000000..522e0072 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/404.mdx @@ -0,0 +1,9 @@ +--- +title: Not Found +template: splash +lastUpdate: true +--- + +import ServerCheck from '../../component/ServerCheck.astro'; + +<ServerCheck /> diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/content.mdx b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/content.mdx new file mode 100644 index 00000000..be10b182 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/content.mdx @@ -0,0 +1,6 @@ +--- +title: Content +lastUpdate: true +--- + +Example page diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/demo.mdx b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/demo.mdx new file mode 100644 index 00000000..e475b6fd --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/demo.mdx @@ -0,0 +1,8 @@ +--- +title: Server Check +lastUpdate: true +--- + +import ServerCheck from '../../component/ServerCheck.astro'; + +<ServerCheck /> diff --git a/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/index.md b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/index.md new file mode 100644 index 00000000..f61f5cd3 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/ssr/src/content/docs/index.md @@ -0,0 +1,18 @@ +--- +title: Home Page +template: splash +lastUpdate: true +hero: + title: Make your docs shine with Starlight + tagline: Everything you need to build a stellar documentation website. Fast, accessible, and easy-to-use. + actions: + - text: Get started + icon: right-arrow + variant: primary + link: /getting-started/ + - text: View on GitHub + icon: external + link: https://github.com/withastro/starlight +--- + +Home page content diff --git a/packages/starlight/__e2e__/git.test.ts b/packages/starlight/__e2e__/git.test.ts new file mode 100644 index 00000000..f73075fe --- /dev/null +++ b/packages/starlight/__e2e__/git.test.ts @@ -0,0 +1,36 @@ +import { makeTestRepo } from '../__tests__/git-utils'; +import { expect, testFactory } from './test-utils'; +import { rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const repoPath = fileURLToPath(new URL('./fixtures/git/', import.meta.url)); + +const test = testFactory(repoPath); + +test.beforeAll(async () => { + // Clears existing nested repo to account for previously interrupted tests. + await rm(join(repoPath, '.git'), { recursive: true, force: true }); + const testRepo = makeTestRepo(repoPath); + + testRepo.commitAllChanges('Add home page', '2024-02-03'); +}); + +test.afterAll(async () => { + // Remove nested repo after test runs + await rm(join(repoPath, '.git'), { recursive: true, force: true }); +}); + +test('include last updated date from git in the footer', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/'); + + await expect(page.locator('footer')).toContainText('Last updated: Feb 3, 2024'); +}); + +test('include git information while developing', async ({ page, makeServer }) => { + const starlight = await makeServer('dev', { mode: 'dev' }); + await starlight.goto('/'); + + await expect(page.locator('footer')).toContainText('Last updated: Feb 3, 2024'); +}); diff --git a/packages/starlight/__e2e__/ssr.test.ts b/packages/starlight/__e2e__/ssr.test.ts new file mode 100644 index 00000000..1bfd6d43 --- /dev/null +++ b/packages/starlight/__e2e__/ssr.test.ts @@ -0,0 +1,66 @@ +import { expect, testFactory } from './test-utils'; +import assert from 'node:assert'; +import { parseHTML } from 'linkedom'; + +const test = testFactory('./fixtures/ssr/'); + +test.beforeEach(() => { + delete process.env.STARLIGHT_PRERENDER; +}); + +test('Render page on the server', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/demo'); + + await expect(page.locator('#server-check')).toHaveText('On server'); +}); + +test('Render 404 page on the server', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); + await starlight.goto('/not-found'); + + await expect(page.locator('#server-check')).toHaveText('On server'); +}); + +test('SSR mode renders the same content page as prerendering', async ({ + getProdServer, + makeServer, +}) => { + const starlight = await getProdServer(); + const ssrContent = await starlight.goto('/content').then((res) => res?.text()); + assert(ssrContent); + + process.env.STARLIGHT_PRERENDER = 'yes'; + const prerenderStarlight = await makeServer('prerender'); + const prerenderContent = await prerenderStarlight.goto('/content').then((res) => res?.text()); + assert(prerenderContent); + + expectEquivalentHTML(prerenderContent, ssrContent); +}); + +test('SSR mode renders the same splash page as prerendering', async ({ + getProdServer, + makeServer, +}) => { + const starlight = await getProdServer(); + const ssrContent = await starlight.goto('/').then((res) => res?.text()); + assert(ssrContent); + + process.env.STARLIGHT_PRERENDER = 'yes'; + const prerenderStarlight = await makeServer('prerender'); + const prerenderContent = await prerenderStarlight.goto('/').then((res) => res?.text()); + assert(prerenderContent); + + expectEquivalentHTML(prerenderContent, ssrContent); +}); + +function expectEquivalentHTML(a: string, b: string) { + expect(getNormalizedHTML(a)).toEqual(getNormalizedHTML(b)); +} + +function getNormalizedHTML(html: string) { + const window = parseHTML(html); + window.document.querySelectorAll('script[src]').forEach((el) => el.setAttribute('src', '')); + window.document.querySelectorAll('link[href]').forEach((el) => el.setAttribute('href', '')); + return window.toString(); +} diff --git a/packages/starlight/__e2e__/tabs.test.ts b/packages/starlight/__e2e__/tabs.test.ts index 6903efb3..45e96c84 100644 --- a/packages/starlight/__e2e__/tabs.test.ts +++ b/packages/starlight/__e2e__/tabs.test.ts @@ -1,8 +1,9 @@ import { expect, testFactory, type Locator } from './test-utils'; -const test = await testFactory('./fixtures/basics/'); +const test = testFactory('./fixtures/basics/'); -test('syncs tabs with a click event', async ({ page, starlight }) => { +test('syncs tabs with a click event', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -22,7 +23,8 @@ test('syncs tabs with a click event', async ({ page, starlight }) => { await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); }); -test('syncs tabs with a keyboard event', async ({ page, starlight }) => { +test('syncs tabs with a keyboard event', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -45,7 +47,8 @@ test('syncs tabs with a keyboard event', async ({ page, starlight }) => { await expectSelectedTab(pkgTabsB, 'npm', 'another npm command'); }); -test('syncs only tabs using the same sync key', async ({ page, starlight }) => { +test('syncs only tabs using the same sync key', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -64,7 +67,8 @@ test('syncs only tabs using the same sync key', async ({ page, starlight }) => { await expectSelectedTab(osTabsB, 'macos', 'ls'); }); -test('supports synced tabs with different tab items', async ({ page, starlight }) => { +test('supports synced tabs with different tab items', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -78,7 +82,8 @@ test('supports synced tabs with different tab items', async ({ page, starlight } await expectSelectedTab(pkgTabsB, 'bun', 'another bun command'); }); -test('persists the focus when syncing tabs', async ({ page, starlight }) => { +test('persists the focus when syncing tabs', async ({ page, getProdServer }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const pkgTabsA = page.locator('starlight-tabs').nth(0); @@ -97,8 +102,9 @@ test('persists the focus when syncing tabs', async ({ page, starlight }) => { test('preserves tabs position when alternating between tabs with different content heights', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs-variable-height'); const tabs = page.locator('starlight-tabs').nth(1); @@ -122,8 +128,9 @@ test('preserves tabs position when alternating between tabs with different conte test('syncs tabs with the same sync key if they do not consistenly use icons', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -143,7 +150,11 @@ test('syncs tabs with the same sync key if they do not consistenly use icons', a await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command'); }); -test('restores tabs only for synced tabs with a persisted state', async ({ page, starlight }) => { +test('restores tabs only for synced tabs with a persisted state', async ({ + page, + getProdServer, +}) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -178,8 +189,9 @@ test('restores tabs only for synced tabs with a persisted state', async ({ page, test('restores tabs for a single set of synced tabs with a persisted state', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -198,8 +210,9 @@ test('restores tabs for a single set of synced tabs with a persisted state', asy test('restores tabs for multiple synced tabs with different sync keys', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); @@ -233,8 +246,9 @@ test('restores tabs for multiple synced tabs with different sync keys', async ({ test('includes the `<starlight-tabs-restore>` element only for synced tabs', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); // The page includes 7 sets of tabs. @@ -245,8 +259,9 @@ test('includes the `<starlight-tabs-restore>` element only for synced tabs', asy test('includes the synced tabs restore script only when needed and at most once', async ({ page, - starlight, + getProdServer, }) => { + const starlight = await getProdServer(); const syncedTabsRestoreScriptRegex = /customElements\.define\('starlight-tabs-restore',/g; await starlight.goto('/tabs'); @@ -260,7 +275,11 @@ test('includes the synced tabs restore script only when needed and at most once' expect((await page.content()).match(syncedTabsRestoreScriptRegex)).toBeNull(); }); -test('gracefully handles invalid persisted state for synced tabs', async ({ page, starlight }) => { +test('gracefully handles invalid persisted state for synced tabs', async ({ + page, + getProdServer, +}) => { + const starlight = await getProdServer(); await starlight.goto('/tabs'); const tabs = page.locator('starlight-tabs'); diff --git a/packages/starlight/__e2e__/test-utils.ts b/packages/starlight/__e2e__/test-utils.ts index c4030cd0..6c98bbd8 100644 --- a/packages/starlight/__e2e__/test-utils.ts +++ b/packages/starlight/__e2e__/test-utils.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; import { test as baseTest, type Page } from '@playwright/test'; -import { build, preview } from 'astro'; +import { build, dev, preview } from 'astro'; export { expect, type Locator } from '@playwright/test'; @@ -9,27 +9,67 @@ process.env.ASTRO_DISABLE_UPDATE_CHECK = 'true'; // Setup a test environment that will build and start a preview server for a given fixture path and // provide a Starlight Playwright fixture accessible from within all tests. -export async function testFactory(fixturePath: string) { - let previewServer: PreviewServer | undefined; +export function testFactory(fixturePath: string) { + const fixturePathUrl = new URL(fixturePath, import.meta.url); + // Combining absolute paths with a `file:` base URL on Windows results + // in a URL with the drive letter as the protocol. + // In that case, use the URL as is instead of interpreting the `file:` protocol. + const root = + fixturePathUrl.protocol === 'file:' + ? fileURLToPath(new URL(fixturePath, import.meta.url)) + : fixturePathUrl.toString(); - const test = baseTest.extend<{ starlight: StarlightPage }>({ - starlight: async ({ page }, use) => { - if (!previewServer) { - throw new Error('Could not find a preview server to run tests against.'); - } + async function makeServer( + options: { + mode?: 'build' | 'dev'; + } = {} + ): Promise<Server> { + const { mode } = options; + if (mode === 'dev') { + return await dev({ + logLevel: 'error', + root, + // Vite's dev server hangs on the optimization discovery phase when + // trying to stop the server programmatically at the end of the test. + // Disabling this optimization here allows the test to run properly + // on CI and fresh clones of the project. + vite: { optimizeDeps: { noDiscovery: true } }, + }); + } else { + await build({ logLevel: 'error', root }); + return await preview({ logLevel: 'error', root }); + } + } - await use(new StarlightPage(previewServer, page)); - }, - }); + // Optimization for tests that don't customize any server options + // to not rebuild the fixture for each test. + let prodServer: Server | null = null; + const servers = new Map<string, Server>(); - test.beforeAll(async () => { - const root = fileURLToPath(new URL(fixturePath, import.meta.url)); - await build({ logLevel: 'error', root }); - previewServer = await preview({ logLevel: 'error', root }); + const test = baseTest.extend<{ + getProdServer: () => Promise<StarlightPage>; + makeServer: (name: string, ...params: Parameters<typeof makeServer>) => Promise<StarlightPage>; + }>({ + getProdServer: ({ page }, use) => + use(async () => { + const server = (prodServer ??= await makeServer({ + mode: 'build', + })); + return new StarlightPage(server, page); + }), + makeServer: ({ page }, use) => + use(async (name, ...params) => { + const server = servers.get(name) ?? (await makeServer(...params)); + servers.set(name, server); + return new StarlightPage(server, page); + }), }); test.afterAll(async () => { - await previewServer?.stop(); + await prodServer?.stop(); + for (const server of servers.values()) { + await server.stop(); + } }); return test; @@ -38,7 +78,7 @@ export async function testFactory(fixturePath: string) { // A Playwright test fixture accessible from within all tests. class StarlightPage { constructor( - private readonly previewServer: PreviewServer, + private readonly server: Server, private readonly page: Page ) {} @@ -49,8 +89,12 @@ class StarlightPage { // Resolve a URL relative to the server used during a test run. resolveUrl(url: string) { - return `http://localhost:${this.previewServer.port}${url.replace(/^\/?/, '/')}`; + const port = 'address' in this.server ? this.server.address.port : this.server.port; + + return `http://localhost:${port}${url.replace(/^\/?/, '/')}`; } } type PreviewServer = Awaited<ReturnType<typeof preview>>; +type DevServer = Awaited<ReturnType<typeof dev>>; +type Server = PreviewServer | DevServer; diff --git a/packages/starlight/__tests__/basics/config-errors.test.ts b/packages/starlight/__tests__/basics/config-errors.test.ts index 3b326360..07424eb9 100644 --- a/packages/starlight/__tests__/basics/config-errors.test.ts +++ b/packages/starlight/__tests__/basics/config-errors.test.ts @@ -65,6 +65,7 @@ test('parses valid config successfully', () => { "locales": undefined, "pagefind": true, "pagination": true, + "prerender": true, "tableOfContents": { "maxHeadingLevel": 3, "minHeadingLevel": 2, diff --git a/packages/starlight/__tests__/basics/git.test.ts b/packages/starlight/__tests__/basics/git.test.ts index fd1b2549..28f6a97b 100644 --- a/packages/starlight/__tests__/basics/git.test.ts +++ b/packages/starlight/__tests__/basics/git.test.ts @@ -1,9 +1,11 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawnSync } from 'node:child_process'; -import { describe, expect, test } from 'vitest'; -import { getNewestCommitDate } from '../../utils/git'; +import { assert, describe, expect, test } from 'vitest'; +import { + getAllNewestCommitDate, + getNewestCommitDate, + makeAPI as makeLiveGitAPI, +} from '../../utils/git'; +import { makeAPI as makeInlineGitAPI } from '../../utils/gitInlined'; +import { makeTestRepo, type ISODate } from '../git-utils'; describe('getNewestCommitDate', () => { const { commitAllChanges, getFilePath, writeFile } = makeTestRepo(); @@ -20,6 +22,20 @@ describe('getNewestCommitDate', () => { expectCommitDateToEqual(getNewestCommitDate(getFilePath(file)), lastCommitDate); }); + test('returns the newest commit date from the wrapped API', () => { + const api = makeLiveGitAPI(getFilePath('')); + + const file = 'updated.md'; + const lastCommitDate = '2023-06-25'; + + writeFile(file, 'content 0'); + commitAllChanges('add updated.md', '2023-06-21'); + writeFile(file, 'content 1'); + commitAllChanges('update updated.md', lastCommitDate); + + expectCommitDateToEqual(api.getNewestCommitDate(file), lastCommitDate); + }); + test('returns the initial commit date for a file never updated', () => { const file = 'added.md'; const commitDate = '2022-09-18'; @@ -70,48 +86,73 @@ describe('getNewestCommitDate', () => { }); }); -function expectCommitDateToEqual(commitDate: CommitDate, expectedDateStr: ISODate) { - const expectedDate = new Date(expectedDateStr); - expect(commitDate).toStrictEqual(expectedDate); -} +describe('getAllNewestCommitDate', () => { + const { commitAllChanges, getFilePath, writeFile } = makeTestRepo(); -function makeTestRepo() { - const repoPath = mkdtempSync(join(tmpdir(), 'starlight-test-git-')); + test('returns the newest commit date', () => { + writeFile('added.md', 'content'); + commitAllChanges('add added.md', '2022-09-18'); - function runInRepo(command: string, args: string[], env: NodeJS.ProcessEnv = {}) { - const result = spawnSync(command, args, { cwd: repoPath, env }); + writeFile('updated.md', 'content 0'); + commitAllChanges('add updated.md', '2023-06-21'); + writeFile('updated.md', 'content 1'); + commitAllChanges('update updated.md', '2023-06-25'); - if (result.status !== 0) { - throw new Error(`Failed to execute test repository command: '${command} ${args.join(' ')}'`); + writeFile('updated with space.md', 'content 0'); + commitAllChanges('add updated.md', '2021-01-01'); + writeFile('updated with space.md', 'content 1'); + commitAllChanges('update updated.md', '2021-01-02'); + + writeFile('updated-same-day.md', 'content 0'); + commitAllChanges('add updated.md', '2023-06-25T12:34:56Z'); + writeFile('updated-same-day.md', 'content 1'); + commitAllChanges('update updated.md', '2023-06-25T14:22:35Z'); + + const latestDates = new Map(getAllNewestCommitDate(getFilePath(''))); + + const expectedDates = new Map<string, ISODate>([ + ['added.md', '2022-09-18'], + ['updated.md', '2023-06-25'], + ['updated with space.md', '2021-01-02'], + ['updated-same-day.md', '2023-06-25T14:22:35Z'], + ]); + + for (const [file, date] of latestDates.entries()) { + const expectedDate = expectedDates.get(file); + assert.ok(expectedDate, `Unexpected tracked file: ${file}`); + expectCommitDateToEqual(new Date(date), expectedDate!); } - } - - // Configure git specifically for this test repository. - runInRepo('git', ['init']); - runInRepo('git', ['config', 'user.name', 'starlight-test']); - runInRepo('git', ['config', 'user.email', 'starlight-test@example.com']); - runInRepo('git', ['config', 'commit.gpgsign', 'false']); - - return { - // The `dateStr` argument should be in the `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ` format. - commitAllChanges(message: string, dateStr: ISODate) { - const date = dateStr.endsWith('Z') ? dateStr : `${dateStr}T00:00:00Z`; - - runInRepo('git', ['add', '-A']); - // This sets both the author and committer dates to the provided date. - runInRepo('git', ['commit', '-m', message, '--date', date], { GIT_COMMITTER_DATE: date }); - }, - getFilePath(name: string) { - return join(repoPath, name); - }, - writeFile(name: string, content: string) { - writeFileSync(join(repoPath, name), content); - }, - }; -} -type ISODate = - | `${number}-${number}-${number}` - | `${number}-${number}-${number}T${number}:${number}:${number}Z`; + for (const file of expectedDates.keys()) { + const latestDate = latestDates.get(file); + assert.ok(latestDate, `Missing tracked file: ${file}`); + } + }); + + test('returns the newest commit date from inlined API', () => { + const api = makeInlineGitAPI(getAllNewestCommitDate(getFilePath(''))); + + const expectedDates = new Map<string, ISODate>([ + ['added.md', '2022-09-18'], + ['updated.md', '2023-06-25'], + ['updated with space.md', '2021-01-02'], + ['updated-same-day.md', '2023-06-25T14:22:35Z'], + ]); + + for (const [file, expectedDate] of expectedDates.entries()) { + const latestDate = api.getNewestCommitDate(file); + expectCommitDateToEqual(latestDate, expectedDate); + } + }); + + test('returns an empty list when the git history for the directory cannot be retrieved', () => { + expect(getAllNewestCommitDate(getFilePath('../not-a-starlight-test-repo'))).toStrictEqual([]); + }); +}); + +function expectCommitDateToEqual(commitDate: CommitDate, expectedDateStr: ISODate) { + const expectedDate = new Date(expectedDateStr); + expect(commitDate).toStrictEqual(expectedDate); +} type CommitDate = ReturnType<typeof getNewestCommitDate>; diff --git a/packages/starlight/__tests__/basics/routing.test.ts b/packages/starlight/__tests__/basics/routing.test.ts index 37c3f9b0..c94e4895 100644 --- a/packages/starlight/__tests__/basics/routing.test.ts +++ b/packages/starlight/__tests__/basics/routing.test.ts @@ -1,7 +1,9 @@ +import { type GetStaticPathsResult } from 'astro'; import { getCollection } from 'astro:content'; import config from 'virtual:starlight/user-config'; import { expect, test, vi } from 'vitest'; -import { routes } from '../../utils/routing'; +import { routes, paths, getRouteBySlugParam } from '../../utils/routing'; +import { slugToParam } from '../../utils/slugs'; vi.mock('astro:content', async () => (await import('../test-utils')).mockedAstroContent({ @@ -42,6 +44,34 @@ test('routes have locale data added', () => { } }); +test('paths contain normalized slugs for path parameters', () => { + const expectedPaths: GetStaticPathsResult = [ + { + params: { slug: '404' }, + props: routes[0]!, + }, + { + params: { slug: undefined }, + props: routes[1]!, + }, + { + params: { slug: 'guides/authoring-content' }, + props: routes[2]!, + }, + ]; + + expect(paths).toEqual(expectedPaths); +}); + +test('routes can be retrieved from their path parameters', () => { + for (const route of routes) { + const params = slugToParam(route.slug); + const routeFromParams = getRouteBySlugParam(params); + + expect(routeFromParams).toBe(route); + } +}); + test('routes includes drafts except in production', async () => { expect(routes.find((route) => route.id === 'guides/authoring-content.mdx')).toBeTruthy(); diff --git a/packages/starlight/__tests__/git-utils.ts b/packages/starlight/__tests__/git-utils.ts new file mode 100644 index 00000000..81999931 --- /dev/null +++ b/packages/starlight/__tests__/git-utils.ts @@ -0,0 +1,76 @@ +import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; + +export function makeTestRepo(onPath?: string) { + const repoPath = realpathSync(onPath ?? mkdtempSync(join(tmpdir(), 'starlight-test-git-'))); + + function runInRepo(command: string, args: string[], env: NodeJS.ProcessEnv = process.env) { + // Format arguments to be shell-friendly for Windows. + const formattedArgs = process.platform === 'win32' ? args.map((arg) => `"${arg}"`) : args; + const result = spawnSync(command, formattedArgs, { + cwd: repoPath, + env, + encoding: 'utf8', + // Run commands using shell on Windows to ensure proper path resolution. + shell: process.platform === 'win32', + windowsVerbatimArguments: true, + }); + + if (result.status !== 0) { + console.log(result.stdout); + console.error(result.stderr); + console.error(result.error); + throw new Error(`Failed to execute test repository command: '${command} ${args.join(' ')}'`); + } + } + + // Configure git specifically for this test repository. + runInRepo('git', ['init']); + runInRepo('git', ['config', 'user.name', 'starlight-test']); + runInRepo('git', ['config', 'user.email', 'starlight-test@example.com']); + runInRepo('git', ['config', 'commit.gpgsign', 'false']); + + return { + runInRepo, + // The `dateStr` argument should be in the `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ` format. + commitAllChanges(message: string, dateStr: ISODate) { + const date = dateStr.endsWith('Z') ? dateStr : `${dateStr}T00:00:00Z`; + + runInRepo('git', ['add', '-A']); + // This sets both the author and committer dates to the provided date. + runInRepo('git', ['commit', '-m', message, '--date', date], { GIT_COMMITTER_DATE: date }); + }, + getFilePath(name: string) { + return join(repoPath, name); + }, + writeFile(name: string, content: string) { + writeFileSync(join(repoPath, name), content); + }, + writeFileTree(fileTree: FileTree) { + writeFileTree(repoPath, fileTree); + }, + }; +} + +export type ISODate = + | `${number}-${number}-${number}` + | `${number}-${number}-${number}T${number}:${number}:${number}Z`; + +// Receive a file tree instead of a flattened list of files so it better works on Windows. +export type FileTree = { [name in string]: TreeEntry }; +type TreeEntry = string | FileTree; + +function writeFileTree(root: string, fileTree: FileTree) { + for (const [name, entry] of Object.entries(fileTree)) { + const path = join(root, name); + + if (typeof entry === 'string') { + writeFileSync(path, entry); + } else { + mkdirSync(path, { recursive: true }); + writeFileTree(path, entry); + } + } +} diff --git a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts index 824eebe3..32654f3a 100644 --- a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts +++ b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts @@ -2,7 +2,7 @@ import config from 'virtual:starlight/user-config'; import { assert, expect, test, vi } from 'vitest'; import { routes } from '../../utils/routing'; import { generateRouteData } from '../../utils/route-data'; -import * as git from '../../utils/git'; +import * as git from 'virtual:starlight/git-info'; vi.mock('astro:content', async () => (await import('../test-utils')).mockedAstroContent({ @@ -82,9 +82,9 @@ test('fallback routes use fallback entry last updated dates', () => { }); expect(getNewestCommitDate).toHaveBeenCalledOnce(); - expect(getNewestCommitDate.mock.lastCall?.[0]).toMatch( - /src[/\\]content[/\\]docs[/\\]guides[/\\]authoring-content.mdx$/ - // ^ no `en/` prefix + expect(getNewestCommitDate).toHaveBeenCalledWith( + 'guides/authoring-content.mdx' + //^ no `en/` prefix ); getNewestCommitDate.mockRestore(); diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts index 1f3de592..b1a34440 100644 --- a/packages/starlight/__tests__/test-config.ts +++ b/packages/starlight/__tests__/test-config.ts @@ -11,17 +11,24 @@ export async function defineVitestConfig( opts?: { build?: Pick<AstroConfig['build'], 'format'>; trailingSlash?: AstroConfig['trailingSlash']; + command?: 'dev' | 'build' | 'preview'; } ) { const root = new URL('./', import.meta.url); const srcDir = new URL('./src/', root); const build = opts?.build ?? { format: 'directory' }; const trailingSlash = opts?.trailingSlash ?? 'ignore'; + const command = opts?.command ?? 'dev'; const { starlightConfig } = await runPlugins(config, plugins, createTestPluginContext()); return getViteConfig({ plugins: [ - vitePluginStarlightUserConfig(starlightConfig, { root, srcDir, build, trailingSlash }), + vitePluginStarlightUserConfig(command, starlightConfig, { + root, + srcDir, + build, + trailingSlash, + }), ], test: { snapshotSerializers: ['./snapshot-serializer-astro-error.ts'], diff --git a/packages/starlight/index.astro b/packages/starlight/index.astro deleted file mode 100644 index 74ba7b1c..00000000 --- a/packages/starlight/index.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import type { InferGetStaticPropsType } from 'astro'; -import { generateRouteData } from './utils/route-data'; -import { paths } from './utils/routing'; - -import Page from './components/Page.astro'; - -export const prerender = true; - -export async function getStaticPaths() { - return paths; -} - -type Props = InferGetStaticPropsType<typeof getStaticPaths>; -const { Content, headings } = await Astro.props.entry.render(); -const route = generateRouteData({ props: { ...Astro.props, headings }, url: Astro.url }); ---- - -<Page {...route}><Content frontmatter={Astro.props.entry.data} /></Page> diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index c13c03bc..d2a5e572 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -50,17 +50,20 @@ export default function StarlightIntegration({ if (!starlightConfig.disable404Route) { injectRoute({ pattern: '404', - entrypoint: '@astrojs/starlight/404.astro', - // Ensure page is pre-rendered even when project is on server output mode - prerender: true, + entrypoint: starlightConfig.prerender + ? '@astrojs/starlight/routes/static/404.astro' + : '@astrojs/starlight/routes/ssr/404.astro', + prerender: starlightConfig.prerender, }); } injectRoute({ pattern: '[...slug]', - entrypoint: '@astrojs/starlight/index.astro', - // Ensure page is pre-rendered even when project is on server output mode - prerender: true, + entrypoint: starlightConfig.prerender + ? '@astrojs/starlight/routes/static/index.astro' + : '@astrojs/starlight/routes/ssr/index.astro', + prerender: starlightConfig.prerender, }); + // Add built-in integrations only if they are not already added by the user through the // config or by a plugin. const allIntegrations = [...config.integrations, ...integrations]; @@ -73,6 +76,7 @@ export default function StarlightIntegration({ if (!allIntegrations.find(({ name }) => name === '@astrojs/mdx')) { integrations.push(mdx({ optimize: true })); } + // Add Starlight directives restoration integration at the end of the list so that remark // plugins injected by Starlight plugins through Astro integrations can handle text and // leaf directives before they are transformed back to their original form. @@ -87,7 +91,7 @@ export default function StarlightIntegration({ updateConfig({ vite: { - plugins: [vitePluginStarlightUserConfig(starlightConfig, config)], + plugins: [vitePluginStarlightUserConfig(command, starlightConfig, config)], }, markdown: { remarkPlugins: [ diff --git a/packages/starlight/integrations/virtual-user-config.ts b/packages/starlight/integrations/virtual-user-config.ts index 75bdd0d0..c0bd6089 100644 --- a/packages/starlight/integrations/virtual-user-config.ts +++ b/packages/starlight/integrations/virtual-user-config.ts @@ -1,7 +1,8 @@ -import type { AstroConfig, ViteUserConfig } from 'astro'; +import type { AstroConfig, HookParameters, ViteUserConfig } from 'astro'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { StarlightConfig } from '../utils/user-config'; +import { getAllNewestCommitDate } from '../utils/git'; function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` { return `\0${id}`; @@ -9,6 +10,7 @@ function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` { /** Vite plugin that exposes Starlight user config and project context via virtual modules. */ export function vitePluginStarlightUserConfig( + command: HookParameters<'astro:config:setup'>['command'], opts: StarlightConfig, { build, @@ -29,6 +31,8 @@ export function vitePluginStarlightUserConfig( const resolveId = (id: string, base = root) => JSON.stringify(id.startsWith('.') ? resolve(fileURLToPath(base), id) : id); + const docsPath = resolve(fileURLToPath(srcDir), 'content/docs'); + const virtualComponentModules = Object.fromEntries( Object.entries(opts.components).map(([name, path]) => [ `virtual:starlight/components/${name}`, @@ -45,6 +49,13 @@ export function vitePluginStarlightUserConfig( srcDir, trailingSlash, })}`, + 'virtual:starlight/git-info': + (command !== 'build' + ? `import { makeAPI } from '${new URL('../utils/git.ts', import.meta.url)}';` + + `const api = makeAPI(${JSON.stringify(docsPath)});` + : `import { makeAPI } from '${new URL('../utils/gitInlined.ts', import.meta.url)}';` + + `const api = makeAPI(${JSON.stringify(getAllNewestCommitDate(docsPath))});`) + + 'export const getNewestCommitDate = api.getNewestCommitDate;', 'virtual:starlight/user-css': opts.customCss.map((id) => `import ${resolveId(id)};`).join(''), 'virtual:starlight/user-images': opts.logo ? 'src' in opts.logo diff --git a/packages/starlight/package.json b/packages/starlight/package.json index 9cb31275..13786616 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -167,8 +167,7 @@ "types": "./integrations/expressive-code/hast.d.ts", "default": "./integrations/expressive-code/hast.mjs" }, - "./index.astro": "./index.astro", - "./404.astro": "./404.astro", + "./routes/*": "./routes/*", "./style/markdown.css": "./style/markdown.css" }, "peerDependencies": { @@ -180,7 +179,8 @@ "@types/node": "^18.16.19", "@vitest/coverage-v8": "^1.6.0", "astro": "^4.15.3", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "linkedom": "^0.18.4" }, "dependencies": { "@astrojs/mdx": "^3.1.3", diff --git a/packages/starlight/routes/common.astro b/packages/starlight/routes/common.astro new file mode 100644 index 00000000..eab058a9 --- /dev/null +++ b/packages/starlight/routes/common.astro @@ -0,0 +1,16 @@ +--- +import { generateRouteData } from '../utils/route-data'; +import type { Route } from '../utils/routing'; +import Page from '../components/Page.astro'; + +export type Props = { + route: Route; +}; + +const { route } = Astro.props; + +const { Content, headings } = await route.entry.render(); +const routeData = generateRouteData({ props: { ...route, headings }, url: Astro.url }); +--- + +<Page {...routeData}><Content frontmatter={route.entry.data} /></Page> diff --git a/packages/starlight/routes/ssr/404.astro b/packages/starlight/routes/ssr/404.astro new file mode 100644 index 00000000..9376b310 --- /dev/null +++ b/packages/starlight/routes/ssr/404.astro @@ -0,0 +1,7 @@ +--- +import FourOhFour from '../static/404.astro'; + +export const prerender = false; +--- + +<FourOhFour /> diff --git a/packages/starlight/routes/ssr/index.astro b/packages/starlight/routes/ssr/index.astro new file mode 100644 index 00000000..9625bd57 --- /dev/null +++ b/packages/starlight/routes/ssr/index.astro @@ -0,0 +1,14 @@ +--- +import { getRouteBySlugParam } from '../../utils/routing'; +import CommonPage from '../common.astro'; + +export const prerender = false; + +const route = getRouteBySlugParam(Astro.params.slug); + +if (route === undefined) { + return new Response(null, { status: 404 }); +} +--- + +<CommonPage route={route} /> diff --git a/packages/starlight/404.astro b/packages/starlight/routes/static/404.astro index 15cabd6a..aa6e4afa 100644 --- a/packages/starlight/404.astro +++ b/packages/starlight/routes/static/404.astro @@ -1,12 +1,11 @@ --- import { getEntry } from 'astro:content'; import config from 'virtual:starlight/user-config'; -import EmptyContent from './components/EmptyMarkdown.md'; -import Page from './components/Page.astro'; -import { generateRouteData } from './utils/route-data'; -import type { StarlightDocsEntry } from './utils/routing'; -import { useTranslations } from './utils/translations'; -import { BuiltInDefaultLocale } from './utils/i18n'; +import EmptyContent from '../../components/EmptyMarkdown.md'; +import type { Route, StarlightDocsEntry } from '../../utils/routing'; +import { useTranslations } from '../../utils/translations'; +import { BuiltInDefaultLocale } from '../../utils/i18n'; +import CommonPage from '../common.astro'; export const prerender = true; @@ -42,11 +41,7 @@ const fallbackEntry: StarlightDocsEntry = { const userEntry = await getEntry('docs', '404'); const entry = userEntry || fallbackEntry; -const { Content, headings } = await entry.render(); -const route = generateRouteData({ - props: { ...entryMeta, entryMeta, headings, entry, id: entry.id, slug: entry.slug }, - url: Astro.url, -}); +const route: Route = { ...entryMeta, entryMeta, entry, id: entry.id, slug: entry.slug }; --- -<Page {...route}><Content /></Page> +<CommonPage {route} /> diff --git a/packages/starlight/routes/static/index.astro b/packages/starlight/routes/static/index.astro new file mode 100644 index 00000000..dfa6f259 --- /dev/null +++ b/packages/starlight/routes/static/index.astro @@ -0,0 +1,15 @@ +--- +import type { InferGetStaticPropsType } from 'astro'; +import { paths } from '../../utils/routing'; +import CommonPage from '../common.astro'; + +export const prerender = true; + +export async function getStaticPaths() { + return paths; +} + +type Props = InferGetStaticPropsType<typeof getStaticPaths>; +--- + +<CommonPage route={Astro.props} /> diff --git a/packages/starlight/utils/git.ts b/packages/starlight/utils/git.ts index d69bd259..e5937883 100644 --- a/packages/starlight/utils/git.ts +++ b/packages/starlight/utils/git.ts @@ -1,7 +1,22 @@ -import { basename, dirname } from 'node:path'; +/** + * Git module to be used from the dev server and from the integration. + */ + +import { basename, dirname, relative, resolve } from 'node:path'; +import { realpathSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -export function getNewestCommitDate(file: string) { +export type GitAPI = { + getNewestCommitDate: (file: string) => Date; +}; + +export const makeAPI = (directory: string): GitAPI => { + return { + getNewestCommitDate: (file) => getNewestCommitDate(resolve(directory, file)), + }; +}; + +export function getNewestCommitDate(file: string): Date { const result = spawnSync('git', ['log', '--format=%ct', '--max-count=1', basename(file)], { cwd: dirname(file), encoding: 'utf-8', @@ -22,3 +37,76 @@ export function getNewestCommitDate(file: string) { const date = new Date(timestamp * 1000); return date; } + +function getRepoRoot(directory: string): string { + const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd: directory, + encoding: 'utf-8', + }); + + if (result.error) { + return directory; + } + + try { + return realpathSync(result.stdout.trim()); + } catch { + return directory; + } +} + +export function getAllNewestCommitDate(directory: string): [string, number][] { + const repoRoot = getRepoRoot(directory); + + const gitLog = spawnSync( + 'git', + [ + 'log', + // Format each history entry as t:<seconds since epoch> + '--format=t:%ct', + // In each entry include the name and status for each modified file + '--name-status', + '--', + directory, + ], + { + cwd: repoRoot, + encoding: 'utf-8', + } + ); + + if (gitLog.error) { + return []; + } + + let runningDate = Date.now(); + const latestDates = new Map<string, number>(); + + for (const logLine of gitLog.stdout.split('\n')) { + if (logLine.startsWith('t:')) { + // t:<seconds since epoch> + runningDate = Number.parseInt(logLine.slice(2)) * 1000; + } + + // - Added files take the format `A\t<file>` + // - Modified files take the format `M\t<file>` + // - Deleted files take the format `D\t<file>` + // - Renamed files take the format `R<count>\t<old>\t<new>` + // - Copied files take the format `C<count>\t<old>\t<new>` + // The name of the file as of the commit being processed is always + // the last part of the log line. + const tabSplit = logLine.lastIndexOf('\t'); + if (tabSplit === -1) continue; + const fileName = logLine.slice(tabSplit + 1); + + const currentLatest = latestDates.get(fileName) || 0; + latestDates.set(fileName, Math.max(currentLatest, runningDate)); + } + + return Array.from(latestDates.entries()).map(([file, date]) => { + const fileFullPath = resolve(repoRoot, file); + const fileInDirectory = relative(directory, fileFullPath); + + return [fileInDirectory, date]; + }); +} diff --git a/packages/starlight/utils/gitInlined.ts b/packages/starlight/utils/gitInlined.ts new file mode 100644 index 00000000..387e67ad --- /dev/null +++ b/packages/starlight/utils/gitInlined.ts @@ -0,0 +1,20 @@ +/** + * Git module to be used on production build results. + * The API is based on inlined git information. + */ + +import type { GitAPI, getAllNewestCommitDate } from './git'; + +type InlinedData = ReturnType<typeof getAllNewestCommitDate>; + +export const makeAPI = (data: InlinedData): GitAPI => { + const trackedDocsFiles = new Map(data); + + return { + getNewestCommitDate: (file) => { + const timestamp = trackedDocsFiles.get(file); + if (!timestamp) throw new Error(`Failed to retrieve the git history for file "${file}"`); + return new Date(timestamp); + }, + }; +}; diff --git a/packages/starlight/utils/plugins.ts b/packages/starlight/utils/plugins.ts index 94f07db2..e0df8ceb 100644 --- a/packages/starlight/utils/plugins.ts +++ b/packages/starlight/utils/plugins.ts @@ -2,6 +2,7 @@ import type { AstroIntegration } from 'astro'; import { z } from 'astro/zod'; import { StarlightConfigSchema, type StarlightUserConfig } from '../utils/user-config'; import { parseWithFriendlyErrors } from '../utils/error-map'; +import { AstroError } from 'astro/errors'; /** * Runs Starlight plugins in the order that they are configured after validating the user-provided @@ -72,6 +73,14 @@ export async function runPlugins( }); } + if (context.config.output === 'static' && !starlightConfig.prerender) { + throw new AstroError( + 'Starlight’s `prerender: false` option requires `output: "hybrid"` or `"server"` in your Astro config.', + 'Either set `output` in your Astro config or set `prerender: true` in the Starlight options.\n\n' + + 'Learn more about rendering modes in the Astro docs: https://docs.astro.build/en/basics/rendering-modes/' + ); + } + return { integrations, starlightConfig }; } diff --git a/packages/starlight/utils/route-data.ts b/packages/starlight/utils/route-data.ts index 0ce36799..6a3e3a2a 100644 --- a/packages/starlight/utils/route-data.ts +++ b/packages/starlight/utils/route-data.ts @@ -1,9 +1,8 @@ import type { MarkdownHeading } from 'astro'; -import { fileURLToPath } from 'node:url'; import project from 'virtual:starlight/project-context'; import config from 'virtual:starlight/user-config'; import { generateToC, type TocItem } from './generateToC'; -import { getNewestCommitDate } from './git'; +import { getNewestCommitDate } from 'virtual:starlight/git-info'; import { getPrevNextLinks, getSidebar, type SidebarEntry } from './navigation'; import { ensureTrailingSlash } from './path'; import type { Route } from './routing'; @@ -82,11 +81,10 @@ function getLastUpdated({ entry }: PageProps): Date | undefined { const { lastUpdated: configLastUpdated } = config; if (frontmatterLastUpdated ?? configLastUpdated) { - const currentFilePath = fileURLToPath(new URL('src/content/docs/' + entry.id, project.root)); try { return frontmatterLastUpdated instanceof Date ? frontmatterLastUpdated - : getNewestCommitDate(currentFilePath); + : getNewestCommitDate(entry.id); } catch { // If the git command fails, ignore the error. return undefined; diff --git a/packages/starlight/utils/routing.ts b/packages/starlight/utils/routing.ts index a0b15fd3..b365fb5f 100644 --- a/packages/starlight/utils/routing.ts +++ b/packages/starlight/utils/routing.ts @@ -100,6 +100,19 @@ function getRoutes(): Route[] { } export const routes = getRoutes(); +function getParamRouteMapping(): ReadonlyMap<string | undefined, Route> { + const map = new Map<string | undefined, Route>(); + for (const route of routes) { + map.set(slugToParam(route.slug), route); + } + return map; +} +const routesBySlugParam = getParamRouteMapping(); + +export function getRouteBySlugParam(slugParam: string | undefined): Route | undefined { + return routesBySlugParam.get(slugParam?.replace(/\/$/, '') || undefined); +} + function getPaths(): Path[] { return routes.map((route) => ({ params: { slug: slugToParam(route.slug) }, diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index 296b8c84..9a47db4c 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -195,7 +195,7 @@ const UserConfigSchema = z.object({ * Set to `false` to disable indexing your site with Pagefind. * This will also hide the default search UI if in use. */ - pagefind: z.boolean().default(true), + pagefind: z.boolean().optional(), /** Specify paths to components that should override Starlight’s default components */ components: ComponentConfigSchema(), @@ -209,6 +209,13 @@ const UserConfigSchema = z.object({ /** Disable Starlight's default 404 page. */ disable404Route: z.boolean().default(false).describe("Disable Starlight's default 404 page."), + /** + * Define whether Starlight pages should be prerendered or not. + * Defaults to always prerender Starlight pages, even when the project is + * set to "server" output mode. + */ + prerender: z.boolean().default(true), + /** Enable displaying a “Built with Starlight” link in your site’s footer. */ credits: z .boolean() @@ -216,8 +223,16 @@ const UserConfigSchema = z.object({ .describe('Enable displaying a “Built with Starlight” link in your site’s footer.'), }); -export const StarlightConfigSchema = UserConfigSchema.strict().transform( - ({ title, locales, defaultLocale, ...config }, ctx) => { +export const StarlightConfigSchema = UserConfigSchema.strict() + .transform((config) => ({ + ...config, + // Pagefind only defaults to true if prerender is also true. + pagefind: config.pagefind ?? config.prerender, + })) + .refine((config) => !(!config.prerender && config.pagefind), { + message: 'Pagefind search is not support with prerendering disabled.', + }) + .transform(({ title, locales, defaultLocale, ...config }, ctx) => { const configuredLocales = Object.keys(locales ?? {}); // This is a multilingual site (more than one locale configured) or a monolingual site with @@ -286,8 +301,7 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( defaultLocale: defaultLocaleConfig, locales: undefined, } as const; - } -); + }); export type StarlightConfig = z.infer<typeof StarlightConfigSchema>; export type StarlightUserConfig = z.input<typeof StarlightConfigSchema>; diff --git a/packages/starlight/virtual.d.ts b/packages/starlight/virtual.d.ts index 1e733866..de3ba265 100644 --- a/packages/starlight/virtual.d.ts +++ b/packages/starlight/virtual.d.ts @@ -14,6 +14,10 @@ declare module 'virtual:starlight/project-context' { export default ProjectContext; } +declare module 'virtual:starlight/git-info' { + export function getNewestCommitDate(file: string): Date; +} + declare module 'virtual:starlight/user-css' {} declare module 'virtual:starlight/user-images' { diff --git a/packages/starlight/vitest.config.ts b/packages/starlight/vitest.config.ts index 70ec5159..e15ce187 100644 --- a/packages/starlight/vitest.config.ts +++ b/packages/starlight/vitest.config.ts @@ -21,10 +21,10 @@ export default defineConfig({ ], thresholds: { autoUpdate: true, - lines: 80.11, - functions: 93.61, - branches: 91.23, - statements: 80.11, + lines: 89.53, + functions: 94.08, + branches: 93.07, + statements: 89.53, }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2911151..e2c7deef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: astro: specifier: ^4.15.3 version: 4.15.3(@types/node@18.16.19)(typescript@5.4.5) + linkedom: + specifier: ^0.18.4 + version: 0.18.4 vitest: specifier: ^1.6.0 version: 1.6.0(@types/node@18.16.19) @@ -262,6 +265,27 @@ importers: specifier: ^4.15.3 version: 4.15.3(@types/node@18.16.19)(typescript@5.4.5) + packages/starlight/__e2e__/fixtures/git: + dependencies: + '@astrojs/starlight': + specifier: workspace:* + version: link:../../.. + astro: + specifier: ^4.15.3 + version: 4.15.3(@types/node@18.16.19)(typescript@5.4.5) + + packages/starlight/__e2e__/fixtures/ssr: + dependencies: + '@astrojs/node': + specifier: ^8.3.2 + version: 8.3.3(astro@4.15.3) + '@astrojs/starlight': + specifier: workspace:* + version: link:../../.. + astro: + specifier: ^4.15.3 + version: 4.15.3(@types/node@18.16.19)(typescript@5.4.5) + packages/tailwind: dependencies: '@astrojs/starlight': @@ -573,6 +597,18 @@ packages: - supports-color dev: false + /@astrojs/node@8.3.3(astro@4.15.3): + resolution: {integrity: sha512-idrKhnnPSi0ABV+PCQsRQqVNwpOvVDF/+fkwcIiE8sr9J8EMvW9g/oyAt8T4X2OBJ8FUzYPL8klfCdG7r0eB5g==} + peerDependencies: + astro: ^4.2.0 + dependencies: + astro: 4.15.3(@types/node@18.16.19)(typescript@5.4.5) + send: 0.18.0 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: false + /@astrojs/prism@3.1.0: resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==} engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} @@ -2931,6 +2967,10 @@ packages: engines: {node: '>=4'} hasBin: true + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: true + /csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} dev: true @@ -2962,6 +3002,17 @@ packages: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} dev: true + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: false + /debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -3041,10 +3092,20 @@ packages: engines: {node: '>=0.4.0'} dev: true + /depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: false + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + /destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dev: false + /detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -3140,6 +3201,10 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + dev: false + /electron-to-chromium@1.5.16: resolution: {integrity: sha512-2gQpi2WYobXmz2q23FrOBYTLcI1O/P4heW3eqX+ldmPVDQELRqhiebV380EhlGG12NtnX1qbK/FHpN0ba+7bLA==} @@ -3159,6 +3224,11 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + /encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + dev: false + /end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: @@ -3288,6 +3358,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + /escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + dev: false + /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -3343,6 +3417,11 @@ packages: dependencies: '@types/estree': 1.0.5 + /etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + dev: false + /event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} dependencies: @@ -3534,6 +3613,11 @@ packages: resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} dev: false + /fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + dev: false + /from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} dev: true @@ -4081,11 +4165,21 @@ packages: domhandler: 5.0.3 domutils: 3.1.0 entities: 4.5.0 - dev: false /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + /http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: false + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -4518,6 +4612,16 @@ packages: /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + /linkedom@0.18.4: + resolution: {integrity: sha512-JhLErxMIEOKByMi3fURXgI1fYOzR87L1Cn0+MI9GlMckFrqFZpV1SUGox1jcKtsKN3y6JgclcQf0FzZT//BuGw==} + dependencies: + css-select: 5.1.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 9.1.0 + uhyphen: 0.2.0 + dev: true + /lite-youtube-embed@0.3.2: resolution: {integrity: sha512-b1dgKyF4PHhinonmr3PB172Nj0qQgA/7DE9EmeIXHR1ksnFEC2olWjNJyJGdsN2cleKHRjjsmrziKlwXtPlmLQ==} dev: false @@ -5202,6 +5306,12 @@ packages: mime-db: 1.52.0 dev: true + /mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + dev: false + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5269,6 +5379,10 @@ packages: resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} engines: {node: '>=10'} + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: false + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true @@ -5422,6 +5536,13 @@ packages: object-keys: 1.1.1 dev: true + /on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -6014,6 +6135,11 @@ packages: engines: {node: '>=8'} dev: true + /range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: false + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -6399,10 +6525,39 @@ packages: engines: {node: '>=10'} hasBin: true + /send@0.18.0: + resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + dev: false + /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true + /setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + dev: false + /sharp@0.32.6: resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} engines: {node: '>=14.15.0'} @@ -6667,6 +6822,11 @@ packages: - supports-color dev: true + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: false + /std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: true @@ -6993,6 +7153,11 @@ packages: dependencies: is-number: 7.0.0 + /toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + dev: false + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: true @@ -7113,6 +7278,10 @@ packages: resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==} dev: true + /uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + dev: true + /ultramatter@0.0.4: resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} dev: false |