Skip to content

Commit 01d33c6

Browse files
authored
feat: add support for non-html ssg routes (#572)
1 parent b0023ba commit 01d33c6

File tree

9 files changed

+194
-11
lines changed

9 files changed

+194
-11
lines changed

.changeset/six-hairs-cross.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root': patch
3+
---
4+
5+
feat: add support for non-html ssg routes

packages/root/src/cli/build.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,15 +409,41 @@ export async function build(rootProjectDir?: string, options?: BuildOptions) {
409409
}
410410

411411
try {
412+
const routeModule = sitemapItem.route.module;
413+
if (routeModule.getStaticContent) {
414+
let props: any;
415+
if (routeModule.getStaticProps) {
416+
props = await routeModule.getStaticProps({
417+
rootConfig, params:
418+
sitemapItem.params,
419+
});
420+
} else {
421+
props = {rootConfig, params: sitemapItem.params};
422+
}
423+
const result = await routeModule.getStaticContent(props);
424+
let body: string;
425+
if (typeof result === 'string') {
426+
body = result;
427+
} else if (result && typeof result === 'object') {
428+
body = result.body;
429+
} else {
430+
body = '';
431+
}
432+
const outFilePath = urlPath.slice(1);
433+
const outPath = path.join(buildDir, outFilePath);
434+
await makeDir(path.dirname(outPath));
435+
await writeFile(outPath, body);
436+
printFileOutput(fileSize(outPath), 'dist/html/', outFilePath);
437+
return;
438+
}
439+
412440
const data = await renderer.renderRoute(sitemapItem.route, {
413441
routeParams: sitemapItem.params,
414442
});
415443
if (data.notFound) {
416444
return;
417445
}
418446

419-
// The renderer currently assumes that all paths serve HTML.
420-
// TODO(stevenle): support non-HTML routes using `routes/[name].[ext].ts`.
421447
let outFilePath = path.join(urlPath.slice(1), 'index.html');
422448
if (outFilePath.endsWith('404/index.html')) {
423449
outFilePath = outFilePath.replace('404/index.html', '404.html');

packages/root/src/core/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,30 @@ export type Handler = (
158158
next: NextFunction
159159
) => void | Promise<void>;
160160

161+
export interface StaticContentResult {
162+
body: string;
163+
contentType?: string;
164+
}
165+
166+
/**
167+
* The `getStaticContent()` function is a SSG handler function for non-HTML
168+
* routes, e.g. `routes/sitemap.xml.ts`.
169+
*
170+
* If the route exports a `getStaticProps()` function, the props returned from
171+
* that function is passed to `getStaticContent()`. Otherwise a default props
172+
* value is passed which includes the `rootConfig` and route param values.
173+
*/
174+
export type GetStaticContent = (props: any) =>
175+
| Promise<StaticContentResult | string>
176+
| StaticContentResult
177+
| string;
178+
161179
export interface RouteModule {
162180
default?: ComponentType<unknown>;
163181
getStaticPaths?: GetStaticPaths;
164182
getStaticProps?: GetStaticProps;
165183
handle?: Handler;
184+
getStaticContent?: GetStaticContent;
166185
}
167186

168187
export interface Route {

packages/root/src/render/render.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from 'node:crypto';
2+
import path from 'node:path';
23
import {
34
ComponentChildren,
45
ComponentType,
@@ -37,6 +38,32 @@ import {htmlPretty} from './html-pretty.js';
3738
import {getFallbackLocales} from './i18n-fallbacks.js';
3839
import {normalizeUrlPath, replaceParams, Router} from './router.js';
3940

41+
const CONTENT_TYPES: Record<string, string> = {
42+
'html': 'text/html',
43+
'htm': 'text/html',
44+
'css': 'text/css',
45+
'js': 'application/javascript',
46+
'json': 'application/json',
47+
'png': 'image/png',
48+
'jpg': 'image/jpeg',
49+
'jpeg': 'image/jpeg',
50+
'gif': 'image/gif',
51+
'svg': 'image/svg+xml',
52+
'txt': 'text/plain',
53+
'xml': 'application/xml',
54+
'pdf': 'application/pdf',
55+
'zip': 'application/zip',
56+
'mp4': 'video/mp4',
57+
'webm': 'video/webm',
58+
'mp3': 'audio/mpeg',
59+
'wav': 'audio/wav',
60+
'woff': 'font/woff',
61+
'woff2': 'font/woff2',
62+
'ttf': 'font/ttf',
63+
'otf': 'font/otf',
64+
'wasm': 'application/wasm',
65+
};
66+
4067
interface RenderHtmlOptions {
4168
/** Attrs passed to the <html> tag, e.g. `{lang: 'en'}`. */
4269
htmlAttrs?: preact.JSX.HTMLAttributes<HTMLHtmlElement>;
@@ -172,6 +199,35 @@ export class Renderer {
172199
res.end(html);
173200
};
174201

202+
if (route.module.getStaticContent) {
203+
let props: any;
204+
if (route.module.getStaticProps) {
205+
props = await route.module.getStaticProps({
206+
rootConfig: this.rootConfig,
207+
params: routeParams,
208+
});
209+
} else {
210+
props = {rootConfig: this.rootConfig, params: routeParams};
211+
}
212+
const result = await route.module.getStaticContent(props);
213+
let body: string | Buffer;
214+
let contentType: string | undefined;
215+
if (typeof result === 'string' || Buffer.isBuffer(result)) {
216+
body = result;
217+
} else if (result && typeof result === 'object') {
218+
body = result.body;
219+
contentType = result.contentType;
220+
} else {
221+
body = '';
222+
}
223+
res.status(200);
224+
const ext = path.extname(route.routePath);
225+
res.set({
226+
'Content-Type': contentType || guessContentType(ext),
227+
});
228+
return res.end(body);
229+
}
230+
175231
if (route.module.handle) {
176232
const handlerContext: HandlerContext = {
177233
route: route,
@@ -452,7 +508,6 @@ export class Renderer {
452508
Object.keys(sitemap)
453509
.sort()
454510
.forEach((urlPath: string) => {
455-
// console.log(urlPath);
456511
const sitemapItem = sitemap[urlPath];
457512
const orderedAlts: Record<string, {hrefLang: string; urlPath: string}> =
458513
{};
@@ -746,3 +801,8 @@ function sortLocales(a: string, b: string) {
746801
}
747802
return a.localeCompare(b);
748803
}
804+
805+
function guessContentType(ext: string): string {
806+
const normalized = ext.trim().toLowerCase().replace(/^\./, '');
807+
return CONTENT_TYPES[normalized] || 'application/octet-stream';
808+
}

packages/root/src/render/router.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class Router {
101101
route: Route
102102
): Promise<Array<{urlPath: string; params: Record<string, string>}>> {
103103
const routeModule = route.module;
104-
if (!routeModule.default) {
104+
if (!routeModule.default && !routeModule.getStaticContent) {
105105
return [];
106106
}
107107

@@ -187,11 +187,14 @@ export function normalizeUrlPath(
187187
urlPath = urlPath.replace(/\/+/g, '/');
188188
// Remove trailing slash.
189189
if (
190-
options?.trailingSlash === false &&
191-
urlPath !== '/' &&
192-
urlPath.endsWith('/')
190+
testPathHasFileExt(urlPath) ||
191+
(
192+
options?.trailingSlash === false &&
193+
urlPath !== '/' &&
194+
urlPath.endsWith('/')
195+
)
193196
) {
194-
urlPath = urlPath.replace(/\/*$/g, '');
197+
urlPath = removeTrailingSlash(urlPath);
195198
}
196199
// Convert `/index` to `/`.
197200
if (urlPath.endsWith('/index')) {
@@ -202,7 +205,11 @@ export function normalizeUrlPath(
202205
urlPath = `/${urlPath}`;
203206
}
204207
// Add trailing slash if needed.
205-
if (options?.trailingSlash && !urlPath.endsWith('/')) {
208+
if (
209+
options?.trailingSlash &&
210+
!testPathHasFileExt(urlPath) &&
211+
!urlPath.endsWith('/')
212+
) {
206213
urlPath = `${urlPath}/`;
207214
}
208215
return urlPath;
@@ -211,12 +218,25 @@ export function normalizeUrlPath(
211218
function testPathHasParams(urlPath: string) {
212219
const segments = urlPath.split('/');
213220
return segments.some((segment) => {
214-
return segment.startsWith('[') && segment.endsWith(']');
221+
return segment.startsWith('[') && segment.includes(']');
215222
});
216223
}
217224

225+
function testPathHasFileExt(urlPath: string) {
226+
const basename = path.basename(removeTrailingSlash(urlPath));
227+
return basename.includes('.');
228+
}
229+
218230
function removeSlashes(str: string) {
219-
return str.replace(/^\/*/g, '').replace(/\/*$/g, '');
231+
return removeTrailingSlash(removeLeadingSlash(str));
232+
}
233+
234+
function removeLeadingSlash(str: string) {
235+
return str.replace(/^\/*/g, '');
236+
}
237+
238+
function removeTrailingSlash(str: string) {
239+
return str.replace(/\/*$/g, '');
220240
}
221241

222242
/**
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {promises as fs} from 'node:fs';
2+
import path from 'node:path';
3+
import {beforeEach, afterEach, test, assert, expect} from 'vitest';
4+
import {fileExists} from '../src/utils/fsutils.js';
5+
import {Fixture, loadFixture} from './testutils.js';
6+
7+
let fixture: Fixture;
8+
9+
beforeEach(async () => {
10+
fixture = await loadFixture('./fixtures/file-route');
11+
});
12+
13+
afterEach(async () => {
14+
if (fixture) {
15+
await fixture.cleanup();
16+
}
17+
});
18+
19+
test('build file route project', async () => {
20+
await fixture.build();
21+
const sitemapPath = path.join(fixture.distDir, 'html/sitemap.xml');
22+
assert.isTrue(await fileExists(sitemapPath));
23+
const xml = await fs.readFile(sitemapPath, 'utf-8');
24+
expect(xml).toBe('<sitemap></sitemap>');
25+
26+
const fooJson = path.join(fixture.distDir, 'html/data/foo.json');
27+
assert.isTrue(await fileExists(fooJson));
28+
const json = await fs.readFile(fooJson, 'utf-8');
29+
expect(json).toBe('{"slug":"foo"}');
30+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
prettyHtml: true,
3+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export async function getStaticPaths() {
2+
return {
3+
paths: [
4+
{params: {slug: 'foo'}},
5+
{params: {slug: 'bar'}},
6+
],
7+
};
8+
}
9+
10+
interface Props {
11+
params: {slug: string};
12+
}
13+
14+
export async function getStaticContent(props: Props) {
15+
const params = props.params;
16+
return JSON.stringify({slug: params.slug});
17+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function getStaticContent() {
2+
return '<sitemap></sitemap>';
3+
}

0 commit comments

Comments
 (0)