Support non-HTML pages (#2586)

* adds support for build non-html pages

* add non-html pages to the static build test suite

* adds getStaticPaths() test for non-html pages

* adds dev server tests for non-html pages

* ading a changeset

* updating changeset description

* testing for building non-html files with async data

* fixing typo in changeset docs
This commit is contained in:
Tony Sullivan 2022-02-15 17:47:42 +00:00 committed by GitHub
parent b8dbba6c4b
commit d6d35bcafc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 367 additions and 53 deletions

View file

@ -0,0 +1,44 @@
---
'astro': patch
---
Support for non-HTML pages
> ⚠️ This feature is currently only supported with the `--experimental-static-build` CLI flag. This feature may be refined over the next few weeks/months as SSR support is finalized.
This adds support for generating non-HTML pages form `.js` and `.ts` pages during the build. Built file and extensions are based on the source file's name, ex: `src/pages/data.json.ts` will be built to `dist/data.json`.
**Is this different from SSR?** Yes! This feature allows JSON, XML, etc. files to be output at build time. Keep an eye out for full SSR support if you need to build similar files when requested, for example as a serverless function in your deployment host.
## Examples
```typescript
// src/pages/company.json.ts
export async function get() {
return {
body: JSON.stringify({
name: 'Astro Technology Company',
url: 'https://astro.build/'
})
}
}
```
What about `getStaticPaths()`? It **just works**™.
```typescript
export async function getStaticPaths() {
return [
{ params: { slug: 'thing1' }},
{ params: { slug: 'thing2' }}
]
}
export async function get(params) {
const { slug } = params
return {
body: // ...JSON.stringify()
}
}
```

View file

@ -304,6 +304,17 @@ export interface RenderPageOptions {
css?: string[];
}
type Body = string;
export interface EndpointOutput<Output extends Body = Body> {
body: Output;
}
export interface EndpointHandler {
[method: string]: (params: any) => EndpointOutput;
}
/**
* Astro Renderer
* Docs: https://docs.astro.build/reference/renderer-reference/
@ -338,13 +349,15 @@ export interface Renderer {
knownEntrypoints?: string[];
}
export type RouteType = 'page' | 'endpoint';
export interface RouteData {
component: string;
generate: (data?: any) => string;
params: string[];
pathname?: string;
pattern: RegExp;
type: 'page';
type: RouteType;
}
export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & {

View file

@ -1,6 +1,6 @@
import type { ViteDevServer } from '../vite.js';
import type { AstroConfig } from '../../@types/astro';
import type { AllPagesData } from './types';
import type { AstroConfig, RouteType } from '../../@types/astro';
import type { AllPagesData, PageBuildData } from './types';
import type { LogOptions } from '../logger';
import type { ViteConfigWithSSR } from '../create-vite.js';
@ -22,6 +22,24 @@ export interface ScanBasedBuildOptions {
viteServer: ViteDevServer;
}
// Returns a filter predicate to filter AllPagesData entries by RouteType
function entryIsType(type: RouteType) {
return function withPage([_, pageData]: [string, PageBuildData]) {
return pageData.route.type === type;
};
}
// Reducer to combine AllPageData entries back into an object keyed by filepath
function reduceEntries<U>(acc: { [key: string]: U }, [key, value]: [string, U]) {
acc[key] = value;
return acc;
}
// Filters an AllPagesData object to only include routes of a specific RouteType
function routesOfType(type: RouteType, allPages: AllPagesData) {
return Object.entries(allPages).filter(entryIsType(type)).reduce(reduceEntries, {});
}
export async function build(opts: ScanBasedBuildOptions) {
const { allPages, astroConfig, logging, origin, pageNames, routeCache, viteConfig, viteServer } = opts;
@ -50,7 +68,7 @@ export async function build(opts: ScanBasedBuildOptions) {
internals,
logging,
origin,
allPages,
allPages: routesOfType('page', allPages),
pageNames,
routeCache,
viteServer,

View file

@ -1,6 +1,6 @@
import type { OutputChunk, OutputAsset, RollupOutput } from 'rollup';
import type { Plugin as VitePlugin, UserConfig, Manifest as ViteManifest } from '../vite';
import type { AstroConfig, ComponentInstance, ManifestData, Renderer } from '../../@types/astro';
import type { AstroConfig, EndpointHandler, ComponentInstance, ManifestData, Renderer, RouteType } from '../../@types/astro';
import type { AllPagesData } from './types';
import type { LogOptions } from '../logger';
import type { ViteConfigWithSSR } from '../create-vite';
@ -122,28 +122,31 @@ export async function staticBuild(opts: StaticBuildOptions) {
for (const [component, pageData] of Object.entries(allPages)) {
const astroModuleURL = new URL('./' + component, astroConfig.projectRoot);
const astroModuleId = prependForwardSlash(component);
const [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata;
const topLevelImports = new Set([
// Any component that gets hydrated
...metadata.hydratedComponentPaths(),
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!),
]);
if (pageData.route.type === 'page') {
const [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata;
// Add hoisted scripts
const hoistedScripts = new Set(metadata.hoistedScriptPaths());
if (hoistedScripts.size) {
const moduleId = npath.posix.join(astroModuleId, 'hoisted.js');
internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedScripts);
topLevelImports.add(moduleId);
}
const topLevelImports = new Set([
// Any component that gets hydrated
...metadata.hydratedComponentPaths(),
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!),
]);
for (const specifier of topLevelImports) {
jsInput.add(specifier);
// Add hoisted scripts
const hoistedScripts = new Set(metadata.hoistedScriptPaths());
if (hoistedScripts.size) {
const moduleId = npath.posix.join(astroModuleId, 'hoisted.js');
internals.hoistedScriptIdToHoistedMap.set(moduleId, hoistedScripts);
topLevelImports.add(moduleId);
}
for (const specifier of topLevelImports) {
jsInput.add(specifier);
}
}
pageInput.add(astroModuleId);
@ -349,7 +352,9 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
const { mod, internals, linkIds, hoistedId, pageData, renderers } = gopts;
// This adds the page name to the array so it can be shown as part of stats.
addPageName(pathname, opts);
if (pageData.route.type === 'page') {
addPageName(pathname, opts);
}
debug('build', `Generating: ${pathname}`);
@ -382,8 +387,8 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
site: astroConfig.buildOptions.site,
});
const outFolder = getOutFolder(astroConfig, pathname);
const outFile = getOutFile(astroConfig, outFolder, pathname);
const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type);
const outFile = getOutFile(astroConfig, outFolder, pathname, pageData.route.type);
await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, html, 'utf-8');
} catch (err) {
@ -464,24 +469,34 @@ function getClientRoot(astroConfig: AstroConfig): URL {
return serverFolder;
}
function getOutFolder(astroConfig: AstroConfig, pathname: string): URL {
function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: RouteType): URL {
const outRoot = getOutRoot(astroConfig);
// This is the root folder to write to.
switch (astroConfig.buildOptions.pageUrlFormat) {
case 'directory':
return new URL('.' + appendForwardSlash(pathname), outRoot);
case 'file':
switch (routeType) {
case 'endpoint':
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
case 'page':
switch (astroConfig.buildOptions.pageUrlFormat) {
case 'directory':
return new URL('.' + appendForwardSlash(pathname), outRoot);
case 'file':
return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
}
}
}
function getOutFile(astroConfig: AstroConfig, outFolder: URL, pathname: string): URL {
switch (astroConfig.buildOptions.pageUrlFormat) {
case 'directory':
return new URL('./index.html', outFolder);
case 'file':
return new URL('./' + npath.basename(pathname) + '.html', outFolder);
function getOutFile(astroConfig: AstroConfig, outFolder: URL, pathname: string, routeType: RouteType): URL {
switch(routeType) {
case 'endpoint':
return new URL(npath.basename(pathname), outFolder);
case 'page':
switch (astroConfig.buildOptions.pageUrlFormat) {
case 'directory':
return new URL('./index.html', outFolder);
case 'file':
return new URL('./' + npath.basename(pathname) + '.html', outFolder);
}
}
}

View file

@ -1,7 +1,7 @@
import type { ComponentInstance, MarkdownRenderOptions, Params, Props, Renderer, RouteData, SSRElement } from '../../@types/astro';
import type { ComponentInstance, EndpointHandler, MarkdownRenderOptions, Params, Props, Renderer, RouteData, SSRElement } from '../../@types/astro';
import type { LogOptions } from '../logger.js';
import { renderPage } from '../../runtime/server/index.js';
import { renderEndpoint, renderPage } from '../../runtime/server/index.js';
import { getParams } from '../routing/index.js';
import { createResult } from './result.js';
import { findPathItemByKey, RouteCache, callGetStaticPaths } from './route-cache.js';
@ -74,6 +74,11 @@ export async function render(opts: RenderOptions): Promise<string> {
pathname,
});
// For endpoints, render the content immediately without injecting scripts or styles
if (route?.type === 'endpoint') {
return renderEndpoint(mod as any as EndpointHandler, params);
}
// Validate the page component before rendering the page
const Component = await mod.default;
if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);

View file

@ -64,7 +64,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
});
}
let html = await coreRender({
let content = await coreRender({
experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild,
links: new Set(),
logging,
@ -91,6 +91,11 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
site: astroConfig.buildOptions.site,
});
if (route?.type === 'endpoint') {
return content;
}
// inject tags
const tags: vite.HtmlTagDescriptor[] = [];
@ -128,20 +133,20 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
});
// add injected tags
html = injectTags(html, tags);
content = injectTags(content, tags);
// run transformIndexHtml() in dev to run Vite dev transformations
if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) {
const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/');
html = await viteServer.transformIndexHtml(relativeURL, html, pathname);
content = await viteServer.transformIndexHtml(relativeURL, content, pathname);
}
// inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?)
if (!/<!doctype html/i.test(html)) {
html = '<!DOCTYPE html>\n' + html;
if (!/<!doctype html/i.test(content)) {
content = '<!DOCTYPE html>\n' + content;
}
return html;
return content;
}
export async function ssr(ssrOpts: SSROptions): Promise<string> {

View file

@ -170,7 +170,8 @@ function comparator(a: Item, b: Item) {
export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?: string }, logging: LogOptions): ManifestData {
const components: string[] = [];
const routes: RouteData[] = [];
const validExtensions: Set<string> = new Set(['.astro', '.md']);
const validPageExtensions: Set<string> = new Set(['.astro', '.md']);
const validEndpointExtensions: Set<string> = new Set(['.js', '.ts']);
function walk(dir: string, parentSegments: Part[][], parentParams: string[]) {
let items: Item[] = [];
@ -189,7 +190,7 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?
return;
}
// filter out "foo.astro_tmp" files, etc
if (!isDir && !validExtensions.has(ext)) {
if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) {
return;
}
const segment = isDir ? basename : name;
@ -209,6 +210,7 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?
const parts = getParts(segment, file);
const isIndex = isDir ? false : basename.startsWith('index.');
const routeSuffix = basename.slice(basename.indexOf('.'), -ext.length);
const isPage = validPageExtensions.has(ext);
items.push({
basename,
@ -217,7 +219,7 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?
file: slash(file),
isDir,
isIndex,
isPage: true,
isPage,
routeSuffix,
});
});
@ -263,12 +265,13 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?
} else {
components.push(item.file);
const component = item.file;
const pattern = getPattern(segments, config.devOptions.trailingSlash);
const generate = getGenerator(segments, config.devOptions.trailingSlash);
const trailingSlash = item.isPage ? config.devOptions.trailingSlash : 'never';
const pattern = getPattern(segments, trailingSlash);
const generate = getGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` : null;
routes.push({
type: 'page',
type: item.isPage ? 'page' : 'endpoint',
pattern,
params,
component,

View file

@ -1,4 +1,4 @@
import type { AstroComponentMetadata, Renderer } from '../../@types/astro';
import type { AstroComponentMetadata, EndpointHandler, Renderer } from '../../@types/astro';
import type { AstroGlobalPartial, SSRResult, SSRElement } from '../../@types/astro';
import shorthash from 'shorthash';
@ -411,6 +411,20 @@ const uniqueElements = (item: any, index: number, all: any[]) => {
return index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children);
};
// Renders an endpoint request to completion, returning the body.
export async function renderEndpoint(mod: EndpointHandler, params: any) {
const method = 'get';
const handler = mod[method];
if (!handler || typeof handler !== 'function') {
throw new Error(`Endpoint handler not found! Expected an exported function for "${method}"`);
}
const { body } = await mod.get(params);
return body;
}
// Renders a page to completion by first calling the factory callback, waiting for its result, and then appending
// styles and scripts into the head.
export async function renderPage(result: SSRResult, Component: AstroComponentFactory, props: any, children: any) {

View file

@ -171,4 +171,64 @@ describe('Development Routing', () => {
expect(response.status).to.equal(500);
});
});
describe('Endpoint routes', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
fixture = await loadFixture({ projectRoot: './fixtures/with-endpoint-routes/' });
devServer = await fixture.startDevServer();
});
after(async () => {
devServer && (await devServer.stop());
});
it('200 when loading /home.json', async () => {
const response = await fixture.fetch('/home.json');
expect(response.status).to.equal(200);
const body = await response.text().then((text) => JSON.parse(text));
expect(body.title).to.equal('home');
});
it('200 when loading /thing1.json', async () => {
const response = await fixture.fetch('/thing1.json');
expect(response.status).to.equal(200);
const body = await response.text().then((text) => JSON.parse(text));
expect(body.slug).to.equal('thing1');
expect(body.title).to.equal('[slug]');
});
it('200 when loading /thing2.json', async () => {
const response = await fixture.fetch('/thing2.json');
expect(response.status).to.equal(200);
const body = await response.text().then((text) => JSON.parse(text));
expect(body.slug).to.equal('thing2');
expect(body.title).to.equal('[slug]');
});
it('200 when loading /data/thing3.json', async () => {
const response = await fixture.fetch('/data/thing3.json');
expect(response.status).to.equal(200);
const body = await response.text().then((text) => JSON.parse(text));
expect(body.slug).to.equal('thing3');
expect(body.title).to.equal('data [slug]');
});
it('200 when loading /data/thing4.json', async () => {
const response = await fixture.fetch('/data/thing4.json');
expect(response.status).to.equal(200);
const body = await response.text().then((text) => JSON.parse(text));
expect(body.slug).to.equal('thing4');
expect(body.title).to.equal('data [slug]');
});
});
});

View file

@ -0,0 +1,14 @@
export async function getStaticPaths() {
return [
{ params: { slug: 'thing1' } },
{ params: { slug: 'thing2' } }
];
}
export async function get() {
return {
body: JSON.stringify({
title: '[slug]'
}, null, 4)
};
}

View file

@ -0,0 +1,8 @@
export async function get() {
return {
body: JSON.stringify({
name: 'Astro Technology Company',
url: 'https://astro.build/'
})
}
}

View file

@ -0,0 +1,16 @@
export async function getStaticPaths() {
return [
{ params: { slug: 'thing1' }},
{ params: { slug: 'thing2' }}
]
}
export async function get(params) {
return {
body: JSON.stringify({
slug: params.slug,
name: 'Astro Technology Company',
url: 'https://astro.build/'
})
}
}

View file

@ -0,0 +1,22 @@
async function fetchPosts() {
const files = import.meta.glob('./posts/**/*.md');
const posts = await Promise.all(
Object.entries(files).map(([filename, load]) => load().then(({ frontmatter }) => {
return {
filename,
title: frontmatter.title,
};
})),
);
return posts.sort((a, b) => a.title.localeCompare(b.title));
}
export async function get() {
const posts = await fetchPosts();
return {
body: JSON.stringify(posts, null, 4),
};
}

View file

@ -1,5 +1,6 @@
---
layout: ../../../layouts/Main.astro
title: More post
---
# Post

View file

@ -1,5 +1,6 @@
---
layout: ../../layouts/Main.astro
title: Thoughts post
---
# Post

View file

@ -0,0 +1,6 @@
export default {
buildOptions: {
site: 'http://example.com/'
}
}

View file

@ -0,0 +1,15 @@
export async function getStaticPaths() {
return [
{ params: { slug: 'thing1' } },
{ params: { slug: 'thing2' } }
];
}
export async function get(params) {
return {
body: JSON.stringify({
slug: params.slug,
title: '[slug]'
})
};
}

View file

@ -0,0 +1,15 @@
export async function getStaticPaths() {
return [
{ params: { slug: 'thing3' } },
{ params: { slug: 'thing4' } }
];
}
export async function get(params) {
return {
body: JSON.stringify({
slug: params.slug,
title: 'data [slug]'
})
};
}

View file

@ -0,0 +1,7 @@
export async function get() {
return {
body: JSON.stringify({
title: 'home'
})
};
}

View file

@ -42,6 +42,38 @@ describe('Static build', () => {
expect(html).to.be.a('string');
});
it('Builds out .json files', async () => {
const content = await fixture.readFile('/subpath/company.json').then((text) => JSON.parse(text));
expect(content.name).to.equal('Astro Technology Company');
expect(content.url).to.equal('https://astro.build/');
});
it ('Builds out async .json files', async () => {
const content = await fixture.readFile('/subpath/posts.json').then((text) => JSON.parse(text));
expect(Array.isArray(content)).to.equal(true);
expect(content).deep.equal([
{
filename: './posts/nested/more.md',
title: 'More post',
},
{
filename: './posts/thoughts.md',
title: 'Thoughts post',
},
]);
});
it('Builds out dynamic .json files', async () => {
const slugs = ['thing1', 'thing2'];
for (const slug of slugs) {
const content = await fixture.readFile(`/subpath/data/${slug}.json`).then((text) => JSON.parse(text));
expect(content.name).to.equal('Astro Technology Company');
expect(content.url).to.equal('https://astro.build/');
expect(content.slug).to.equal(slug);
}
});
function createFindEvidence(expected) {
return async function findEvidence(pathname) {
const html = await fixture.readFile(pathname);