Skip to content

Commit

Permalink
Support non-HTML pages (#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 authored Feb 15, 2022
1 parent b8dbba6 commit d6d35bc
Show file tree
Hide file tree
Showing 20 changed files with 369 additions and 55 deletions.
44 changes: 44 additions & 0 deletions .changeset/few-coats-warn.md
Original file line number Diff line number Diff line change
@@ -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()
}
}
```
15 changes: 14 additions & 1 deletion packages/astro/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 packages/astro/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 packages/astro/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 packages/astro/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 packages/astro/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 packages/astro/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
Loading

0 comments on commit d6d35bc

Please sign in to comment.