Skip to content

Commit

Permalink
Support non-HTML pages (withastro#2586)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Tony Sullivan committed Feb 15, 2022
1 parent 1f9031b commit 4158dce
Show file tree
Hide file tree
Showing 19 changed files with 325 additions and 55 deletions.
15 changes: 14 additions & 1 deletion src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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'> & {
Expand Down
24 changes: 21 additions & 3 deletions src/core/build/scan-based-build.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand Down Expand Up @@ -50,7 +68,7 @@ export async function build(opts: ScanBasedBuildOptions) {
internals,
logging,
origin,
allPages,
allPages: routesOfType('page', allPages),
pageNames,
routeCache,
viteServer,
Expand Down
87 changes: 51 additions & 36 deletions src/core/build/static-build.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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!),
]);

// 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);
if (pageData.route.type === 'page') {
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!),
]);

// 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);
Expand Down Expand Up @@ -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}`);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/core/render/core.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`);
Expand Down
17 changes: 11 additions & 6 deletions src/core/render/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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> {
Expand Down
15 changes: 9 additions & 6 deletions src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -217,7 +219,7 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?
file: slash(file),
isDir,
isIndex,
isPage: true,
isPage,
routeSuffix,
});
});
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/runtime/server/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 4158dce

Please sign in to comment.