Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow passing fetch() Response to set:html #4832

Merged
merged 5 commits into from
Sep 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/silent-comics-hang.md
Original file line number Diff line number Diff line change
@@ -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
<div set:html={fetch('/legacy-post.html')}></div>
```

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
<div set:html={new Response('<span>Hello world</span>', {
headers: {
'content-type': 'text/html'
}
})}></div>
```
* `ReadableStream`s:
```astro
<div set:html={new ReadableStream({
start(controller) {
controller.enqueue(`<span>read me</span>`);
controller.close();
}
})}></div>
```
* `AsyncIterable`s:
```astro
<div set:html={(async function * () {
for await (const num of [1, 2, 3, 4, 5]) {
yield `<li>${num}</li>`;
}
})()}>
```
* `Iterable`s (non-async):
```astro
<div set:html={(function * () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generators!

for (const num of [1, 2, 3, 4, 5]) {
yield `<li>${num}</li>`;
}
})()}>
```
67 changes: 60 additions & 7 deletions packages/astro/src/runtime/server/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Uint8Array>): any {
for await (const chunk of iterable) {
yield unescapeHTML(chunk as BlessedType);
}
}

function * unescapeChunks(iterable: Iterable<any>): any {
for(const chunk of iterable) {
yield unescapeHTML(chunk);
}
}

export function unescapeHTML(str: any): BlessedType | Promise<BlessedType | AsyncGenerator<BlessedType, void, unknown>> | AsyncGenerator<BlessedType, void, unknown> {
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<Uint8Array>;
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);
}
2 changes: 1 addition & 1 deletion packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions packages/astro/src/runtime/server/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -122,7 +123,7 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise<any> {
}
await Promise.all(slotPromises);

let output: string | AsyncIterable<string | RenderInstruction>;
let output: string | AsyncIterable<string | HTMLBytes | RenderInstruction>;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
output = await renderComponent(
result,
Expand All @@ -141,12 +142,11 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise<any> {
);
}
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);
}
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/runtime/server/render/any.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export async function* renderChild(child: any): AsyncIterable<any> {
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;
Expand Down
14 changes: 7 additions & 7 deletions packages/astro/src/runtime/server/render/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -62,7 +62,7 @@ export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory

export async function* renderAstroComponent(
component: InstanceType<typeof AstroComponent>
): AsyncIterable<string | RenderInstruction> {
): AsyncIterable<string | HTMLBytes | RenderInstruction> {
for await (const value of component) {
if (value || value === 0) {
for await (const chunk of renderChild(value)) {
Expand Down Expand Up @@ -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(
Expand All @@ -108,7 +108,7 @@ export async function renderToIterable(
displayName: string,
props: any,
children: any
): Promise<AsyncIterable<string | RenderInstruction>> {
): Promise<AsyncIterable<string | HTMLBytes | RenderInstruction>> {
validateComponentProps(props, displayName);
const Component = await componentFactory(result, props, children);

Expand Down
57 changes: 56 additions & 1 deletion packages/astro/src/runtime/server/render/common.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -40,3 +43,55 @@ export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruct
}
}
}

export class HTMLParts {
public parts: Array<HTMLBytes | string>;
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<Uint8Array>) {
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;
}
6 changes: 3 additions & 3 deletions packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,7 +54,7 @@ export async function renderComponent(
Component: unknown,
_props: Record<string | number, any>,
slots: any = {}
): Promise<string | AsyncIterable<string | RenderInstruction>> {
): Promise<string | AsyncIterable<string | HTMLBytes | RenderInstruction>> {
Component = await Component;

switch (getComponentType(Component)) {
Expand Down Expand Up @@ -84,7 +84,7 @@ export async function renderComponent(

case 'astro-factory': {
async function* renderAstroComponentInline(): AsyncGenerator<
string | RenderInstruction,
string | HTMLBytes | RenderInstruction,
void,
undefined
> {
Expand Down
Loading