diff --git a/.changeset/silent-comics-hang.md b/.changeset/silent-comics-hang.md
new file mode 100644
index 000000000..7434b552e
--- /dev/null
+++ b/.changeset/silent-comics-hang.md
@@ -0,0 +1,49 @@
+---
+'astro': minor
+---
+
+Allows Responses to be passed to set:html
+
+This expands the abilities of `set:html` to ultimate service this use-case:
+
+```astro
+
+```
+
+This means you can take a legacy app that has been statically generated to HTML and directly consume that HTML within your templates. As is always the case with `set:html`, this should only be used on trusted content.
+
+To make this possible, you can also pass several other types into `set:html` now:
+
+* `Response` objects, since that is what fetch() returns:
+ ```astro
+ Hello world', {
+ headers: {
+ 'content-type': 'text/html'
+ }
+ })}>
+ ```
+* `ReadableStream`s:
+ ```astro
+ read me`);
+ controller.close();
+ }
+ })}>
+ ```
+* `AsyncIterable`s:
+ ```astro
+ ${num}`;
+ }
+ })()}>
+ ```
+* `Iterable`s (non-async):
+ ```astro
+
${num}`;
+ }
+ })()}>
+ ```
diff --git a/packages/astro/src/runtime/server/escape.ts b/packages/astro/src/runtime/server/escape.ts
index 0212d803d..0118c17f3 100644
--- a/packages/astro/src/runtime/server/escape.ts
+++ b/packages/astro/src/runtime/server/escape.ts
@@ -3,11 +3,24 @@ import { escape } from 'html-escaper';
// Leverage the battle-tested `html-escaper` npm package.
export const escapeHTML = escape;
+export class HTMLBytes extends Uint8Array {
+ // @ts-ignore
+ get [Symbol.toStringTag]() {
+ return 'HTMLBytes';
+ }
+}
+
/**
* A "blessed" extension of String that tells Astro that the string
* has already been escaped. This helps prevent double-escaping of HTML.
*/
-export class HTMLString extends String {}
+export class HTMLString extends String {
+ get [Symbol.toStringTag]() {
+ return 'HTMLString';
+ }
+}
+
+type BlessedType = string | HTMLBytes;
/**
* markHTMLString marks a string as raw or "already escaped" by returning
@@ -30,12 +43,52 @@ export const markHTMLString = (value: any) => {
return value;
};
-export function unescapeHTML(str: any) {
- // If a promise, await the result and mark that.
- if (!!str && typeof str === 'object' && typeof str.then === 'function') {
- return Promise.resolve(str).then((value) => {
- return markHTMLString(value);
- });
+export function isHTMLString(value: any): value is HTMLString {
+ return Object.prototype.toString.call(value) === '[object HTMLString]';
+}
+
+function markHTMLBytes(bytes: Uint8Array) {
+ return new HTMLBytes(bytes);
+}
+
+export function isHTMLBytes(value: any): value is HTMLBytes {
+ return Object.prototype.toString.call(value) === '[object HTMLBytes]';
+}
+
+async function * unescapeChunksAsync(iterable: AsyncIterable
): any {
+ for await (const chunk of iterable) {
+ yield unescapeHTML(chunk as BlessedType);
+ }
+}
+
+function * unescapeChunks(iterable: Iterable): any {
+ for(const chunk of iterable) {
+ yield unescapeHTML(chunk);
+ }
+}
+
+export function unescapeHTML(str: any): BlessedType | Promise> | AsyncGenerator {
+ if (!!str && typeof str === 'object') {
+ if(str instanceof Uint8Array) {
+ return markHTMLBytes(str);
+ }
+ // If a response, stream out the chunks
+ else if(str instanceof Response && str.body) {
+ const body = str.body as unknown as AsyncIterable;
+ return unescapeChunksAsync(body);
+ }
+ // If a promise, await the result and mark that.
+ else if(typeof str.then === 'function') {
+ return Promise.resolve(str).then((value) => {
+ return unescapeHTML(value);
+ });
+ }
+ else if(Symbol.iterator in str) {
+ return unescapeChunks(str);
+ }
+ else if(Symbol.asyncIterator in str) {
+ return unescapeChunksAsync(str);
+ }
}
return markHTMLString(str);
}
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
index c6d7c9e53..8ffb64611 100644
--- a/packages/astro/src/runtime/server/index.ts
+++ b/packages/astro/src/runtime/server/index.ts
@@ -1,6 +1,6 @@
export { createAstro } from './astro-global.js';
export { renderEndpoint } from './endpoint.js';
-export { escapeHTML, HTMLString, markHTMLString, unescapeHTML } from './escape.js';
+export { escapeHTML, HTMLString, HTMLBytes, markHTMLString, unescapeHTML } from './escape.js';
export type { Metadata } from './metadata';
export { createMetadata } from './metadata.js';
export {
diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts
index 95a347abc..005949733 100644
--- a/packages/astro/src/runtime/server/jsx.ts
+++ b/packages/astro/src/runtime/server/jsx.ts
@@ -4,14 +4,15 @@ import { AstroJSX, isVNode } from '../../jsx-runtime/index.js';
import {
escapeHTML,
HTMLString,
+ HTMLBytes,
markHTMLString,
renderComponent,
RenderInstruction,
renderToString,
spreadAttributes,
- stringifyChunk,
voidElementNames,
} from './index.js';
+import { HTMLParts } from './render/common.js';
const ClientOnlyPlaceholder = 'astro-client-only';
@@ -122,7 +123,7 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise {
}
await Promise.all(slotPromises);
- let output: string | AsyncIterable;
+ let output: string | AsyncIterable;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
output = await renderComponent(
result,
@@ -141,12 +142,11 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise {
);
}
if (typeof output !== 'string' && Symbol.asyncIterator in output) {
- let body = '';
+ let parts = new HTMLParts();
for await (const chunk of output) {
- let html = stringifyChunk(result, chunk);
- body += html;
+ parts.append(chunk, result);
}
- return markHTMLString(body);
+ return markHTMLString(parts.toString());
} else {
return markHTMLString(output);
}
diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts
index 9c6e199c2..4da9fe7c4 100644
--- a/packages/astro/src/runtime/server/render/any.ts
+++ b/packages/astro/src/runtime/server/render/any.ts
@@ -27,7 +27,9 @@ export async function* renderChild(child: any): AsyncIterable {
Object.prototype.toString.call(child) === '[object AstroComponent]'
) {
yield* renderAstroComponent(child);
- } else if (typeof child === 'object' && Symbol.asyncIterator in child) {
+ } else if(ArrayBuffer.isView(child)) {
+ yield child;
+ } else if (typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child)) {
yield* child;
} else {
yield child;
diff --git a/packages/astro/src/runtime/server/render/astro.ts b/packages/astro/src/runtime/server/render/astro.ts
index f6ec92acb..3b8f5af4e 100644
--- a/packages/astro/src/runtime/server/render/astro.ts
+++ b/packages/astro/src/runtime/server/render/astro.ts
@@ -2,10 +2,10 @@ import type { SSRResult } from '../../../@types/astro';
import type { AstroComponentFactory } from './index';
import type { RenderInstruction } from './types';
-import { markHTMLString } from '../escape.js';
+import { markHTMLString, HTMLBytes } from '../escape.js';
import { HydrationDirectiveProps } from '../hydration.js';
import { renderChild } from './any.js';
-import { stringifyChunk } from './common.js';
+import { HTMLParts } from './common.js';
// In dev mode, check props and make sure they are valid for an Astro component
function validateComponentProps(props: any, displayName: string) {
@@ -62,7 +62,7 @@ export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory
export async function* renderAstroComponent(
component: InstanceType
-): AsyncIterable {
+): AsyncIterable {
for await (const value of component) {
if (value || value === 0) {
for await (const chunk of renderChild(value)) {
@@ -95,11 +95,11 @@ export async function renderToString(
throw response;
}
- let html = '';
+ let parts = new HTMLParts();
for await (const chunk of renderAstroComponent(Component)) {
- html += stringifyChunk(result, chunk);
+ parts.append(chunk, result);
}
- return html;
+ return parts.toString();
}
export async function renderToIterable(
@@ -108,7 +108,7 @@ export async function renderToIterable(
displayName: string,
props: any,
children: any
-): Promise> {
+): Promise> {
validateComponentProps(props, displayName);
const Component = await componentFactory(result, props, children);
diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts
index 27b012aa4..9be47d230 100644
--- a/packages/astro/src/runtime/server/render/common.ts
+++ b/packages/astro/src/runtime/server/render/common.ts
@@ -1,7 +1,7 @@
import type { SSRResult } from '../../../@types/astro';
import type { RenderInstruction } from './types.js';
-import { markHTMLString } from '../escape.js';
+import { markHTMLString, HTMLBytes, isHTMLString } from '../escape.js';
import {
determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript,
@@ -12,6 +12,9 @@ import {
export const Fragment = Symbol.for('astro:fragment');
export const Renderer = Symbol.for('astro:renderer');
+export const encoder = new TextEncoder();
+export const decoder = new TextDecoder();
+
// Rendering produces either marked strings of HTML or instructions for hydration.
// These directive instructions bubble all the way up to renderPage so that we
// can ensure they are added only once, and as soon as possible.
@@ -40,3 +43,55 @@ export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruct
}
}
}
+
+export class HTMLParts {
+ public parts: Array;
+ constructor() {
+ this.parts = [];
+ }
+ append(part: string | HTMLBytes | RenderInstruction, result: SSRResult) {
+ if(ArrayBuffer.isView(part)) {
+ this.parts.push(part);
+ } else {
+ this.parts.push(stringifyChunk(result, part));
+ }
+ }
+ toString() {
+ let html = '';
+ for(const part of this.parts) {
+ if(ArrayBuffer.isView(part)) {
+ html += decoder.decode(part);
+ } else {
+ html += part;
+ }
+ }
+ return html;
+ }
+ toArrayBuffer() {
+ this.parts.forEach((part, i) => {
+ if(typeof part === 'string') {
+ this.parts[i] = encoder.encode(String(part));
+ }
+ });
+ return concatUint8Arrays(this.parts as Uint8Array[]);
+ }
+}
+
+export function chunkToByteArray(result: SSRResult, chunk: string | HTMLBytes | RenderInstruction): Uint8Array {
+ if(chunk instanceof Uint8Array) {
+ return chunk as Uint8Array;
+ }
+ return encoder.encode(stringifyChunk(result, chunk));
+}
+
+export function concatUint8Arrays(arrays: Array) {
+ let len = 0;
+ arrays.forEach(arr => len += arr.length);
+ let merged = new Uint8Array(len);
+ let offset = 0;
+ arrays.forEach(arr => {
+ merged.set(arr, offset);
+ offset += arr.length;
+ });
+ return merged;
+}
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
index 75398d2b9..3d10be33f 100644
--- a/packages/astro/src/runtime/server/render/component.ts
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -1,7 +1,7 @@
import type { AstroComponentMetadata, SSRLoadedRenderer, SSRResult } from '../../../@types/astro';
import type { RenderInstruction } from './types.js';
-import { markHTMLString } from '../escape.js';
+import { markHTMLString, HTMLBytes } from '../escape.js';
import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js';
@@ -54,7 +54,7 @@ export async function renderComponent(
Component: unknown,
_props: Record,
slots: any = {}
-): Promise> {
+): Promise> {
Component = await Component;
switch (getComponentType(Component)) {
@@ -84,7 +84,7 @@ export async function renderComponent(
case 'astro-factory': {
async function* renderAstroComponentInline(): AsyncGenerator<
- string | RenderInstruction,
+ string | HTMLBytes | RenderInstruction,
void,
undefined
> {
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
index cacaf7dd0..131b07fb1 100644
--- a/packages/astro/src/runtime/server/render/page.ts
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -3,11 +3,11 @@ import type { AstroComponentFactory } from './index';
import { createResponse } from '../response.js';
import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from './astro.js';
-import { stringifyChunk } from './common.js';
+import { encoder, chunkToByteArray, HTMLParts } from './common.js';
import { renderComponent } from './component.js';
+import { isHTMLString } from '../escape.js';
import { maybeRenderHead } from './head.js';
-const encoder = new TextEncoder();
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
type NonAstroPageComponent = {
@@ -72,17 +72,16 @@ export async function renderPage(
let i = 0;
try {
for await (const chunk of iterable) {
- let html = stringifyChunk(result, chunk);
-
- if (i === 0) {
- if (!/\n'));
+ if(isHTMLString(chunk)) {
+ if (i === 0) {
+ if (!/\n'));
+ }
}
}
- // Convert HTML object to string
- // for environments that won't "toString" automatically
- // (ex. Cloudflare and Vercel Edge)
- controller.enqueue(encoder.encode(String(html)));
+
+ let bytes = chunkToByteArray(result, chunk);
+ controller.enqueue(bytes);
i++;
}
controller.close();
@@ -94,20 +93,21 @@ export async function renderPage(
},
});
} else {
- body = '';
+ let parts = new HTMLParts();
let i = 0;
for await (const chunk of iterable) {
- let html = stringifyChunk(result, chunk);
- if (i === 0) {
- if (!/\n';
+ if(isHTMLString(chunk)) {
+ if (i === 0) {
+ if (!/\n', result);
+ }
}
}
- body += html;
+ parts.append(chunk, result);
i++;
}
- const bytes = encoder.encode(body);
- headers.set('Content-Length', bytes.byteLength.toString());
+ body = parts.toArrayBuffer();
+ headers.set('Content-Length', body.byteLength.toString());
}
let response = createResponse(body, { ...init, headers });
diff --git a/packages/astro/src/runtime/server/response.ts b/packages/astro/src/runtime/server/response.ts
index 9145c23bd..ae374d1aa 100644
--- a/packages/astro/src/runtime/server/response.ts
+++ b/packages/astro/src/runtime/server/response.ts
@@ -59,7 +59,7 @@ type CreateResponseFn = (body?: BodyInit | null, init?: ResponseInit) => Respons
export const createResponse: CreateResponseFn = isNodeJS
? (body, init) => {
- if (typeof body === 'string') {
+ if (typeof body === 'string' || ArrayBuffer.isView(body)) {
return new Response(body, init);
}
if (typeof StreamingCompatibleResponse === 'undefined') {
diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts
index 24dfa2b1a..65cbfa0f5 100644
--- a/packages/astro/src/runtime/server/util.ts
+++ b/packages/astro/src/runtime/server/util.ts
@@ -1,9 +1,3 @@
-function formatList(values: string[]): string {
- if (values.length === 1) {
- return values[0];
- }
- return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
-}
export function serializeListValue(value: any) {
const hash: Record = {};
@@ -34,3 +28,7 @@ export function serializeListValue(value: any) {
}
}
}
+
+export function isPromise(value: any): value is Promise {
+ return !!value && typeof value === 'object' && typeof value.then === 'function';
+}
diff --git a/packages/astro/test/fixtures/set-html/package.json b/packages/astro/test/fixtures/set-html/package.json
new file mode 100644
index 000000000..8310c0560
--- /dev/null
+++ b/packages/astro/test/fixtures/set-html/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@test/set-html",
+ "version": "1.0.0",
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/set-html/public/test.html b/packages/astro/test/fixtures/set-html/public/test.html
new file mode 100644
index 000000000..227e8db08
--- /dev/null
+++ b/packages/astro/test/fixtures/set-html/public/test.html
@@ -0,0 +1 @@
+works
diff --git a/packages/astro/test/fixtures/set-html/src/pages/fetch.astro b/packages/astro/test/fixtures/set-html/src/pages/fetch.astro
new file mode 100644
index 000000000..d03f45f7f
--- /dev/null
+++ b/packages/astro/test/fixtures/set-html/src/pages/fetch.astro
@@ -0,0 +1,18 @@
+---
+// This is a dev only test
+const mode = import.meta.env.MODE;
+---
+
+
+ Testing
+
+
+ Testing
+
+
+
diff --git a/packages/astro/test/fixtures/set-html/src/pages/index.astro b/packages/astro/test/fixtures/set-html/src/pages/index.astro
new file mode 100644
index 000000000..d70061169
--- /dev/null
+++ b/packages/astro/test/fixtures/set-html/src/pages/index.astro
@@ -0,0 +1,36 @@
+---
+function * iterator(id = 'iterator') {
+ for(const num of [1, 2, 3, 4, 5]) {
+ yield `${num}`;
+ }
+}
+
+async function * asynciterator() {
+ for(const num of iterator('asynciterator')) {
+ yield Promise.resolve(num);
+ }
+}
+---
+
+
+ Testing
+
+
+ Testing
+ works`}>
+ works`)}>
+ `, {
+ headers: {
+ 'content-type': 'text/html'
+ }
+ })}>
+
+
+ read me`);
+ controller.close();
+ },
+ })}>
+
+
diff --git a/packages/astro/test/set-html.test.js b/packages/astro/test/set-html.test.js
new file mode 100644
index 000000000..ed626c761
--- /dev/null
+++ b/packages/astro/test/set-html.test.js
@@ -0,0 +1,82 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import { loadFixture } from './test-utils.js';
+
+
+describe('set:html', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/set-html/',
+ });
+ });
+
+ describe('Development', () => {
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ globalThis.TEST_FETCH = (fetch, url, init) => {
+ return fetch(fixture.resolveUrl(url), init);
+ };
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('can take a fetch()', async () => {
+ let res = await fixture.fetch('/fetch');
+ expect(res.status).to.equal(200);
+ let html = await res.text();
+ const $ = cheerio.load(html);
+ expect($('#fetched-html')).to.have.a.lengthOf(1);
+ expect($('#fetched-html').text()).to.equal('works');
+ });
+ });
+
+ describe('Build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('can take a string of HTML', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#html-inner')).to.have.a.lengthOf(1);
+ });
+
+ it('can take a Promise to a string of HTML', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#promise-html-inner')).to.have.a.lengthOf(1);
+ });
+
+ it('can take a Response to a string of HTML', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#response-html-inner')).to.have.a.lengthOf(1);
+ });
+
+ it('can take an Iterator', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#iterator-num')).to.have.a.lengthOf(5);
+ });
+
+ it('Can take an AsyncIterator', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#asynciterator-num')).to.have.a.lengthOf(5);
+ });
+
+ it('Can take a ReadableStream', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+ expect($('#readable-inner')).to.have.a.lengthOf(1);
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e92f7572..2f1f8a6f0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1838,6 +1838,12 @@ importers:
dependencies:
astro: link:../../..
+ packages/astro/test/fixtures/set-html:
+ specifiers:
+ astro: workspace:*
+ dependencies:
+ astro: link:../../..
+
packages/astro/test/fixtures/slots-preact:
specifiers:
'@astrojs/mdx': workspace:*