diff --git a/.changeset/chatty-spies-wink.md b/.changeset/chatty-spies-wink.md new file mode 100644 index 000000000000..869a5297222c --- /dev/null +++ b/.changeset/chatty-spies-wink.md @@ -0,0 +1,8 @@ +--- +'astro': minor +--- + +Add support for the `set:html` and `set:text` directives. + +With the introduction of these directives, unescaped HTML content in expressions is now deprecated. Please migrate to `set:html` in order to continue injecting unescaped HTML in future versions of Astro—you can use `` to avoid a wrapper element. `set:text` allows you to opt-in to escaping now, but it will soon become the default. + diff --git a/.changeset/mighty-lamps-drive.md b/.changeset/mighty-lamps-drive.md index dfb6e721736e..8a992457bf9e 100644 --- a/.changeset/mighty-lamps-drive.md +++ b/.changeset/mighty-lamps-drive.md @@ -2,4 +2,4 @@ 'astro': patch --- -Bug fix for define:vars with the --experimental-static-build flag +Bug fix for `define:vars` with the --experimental-static-build flag diff --git a/packages/astro/components/Code.astro b/packages/astro/components/Code.astro index 1fc319d08785..acebfce04381 100644 --- a/packages/astro/components/Code.astro +++ b/packages/astro/components/Code.astro @@ -47,4 +47,4 @@ const _html = highlighter.codeToHtml(code, lang); const html = repairShikiTheme(_html); --- -{html} + diff --git a/packages/astro/components/Markdown.astro b/packages/astro/components/Markdown.astro index cb04ef065cf8..feff437a65f5 100644 --- a/packages/astro/components/Markdown.astro +++ b/packages/astro/components/Markdown.astro @@ -41,4 +41,4 @@ if (content) { html = htmlContent; --- -{html ? html : } +{html ? : } diff --git a/packages/astro/components/Prism.astro b/packages/astro/components/Prism.astro index 9e9db34592ec..5f92b1f667e4 100644 --- a/packages/astro/components/Prism.astro +++ b/packages/astro/components/Prism.astro @@ -46,4 +46,4 @@ if (grammar) { } --- -
{html}
+
diff --git a/packages/astro/package.json b/packages/astro/package.json index af37cae240c8..2891c1797f90 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -55,7 +55,7 @@ "test:match": "mocha --timeout 15000 -g" }, "dependencies": { - "@astrojs/compiler": "^0.10.0", + "@astrojs/compiler": "^0.10.1", "@astrojs/language-server": "^0.8.6", "@astrojs/markdown-remark": "^0.6.0", "@astrojs/prism": "0.4.0", diff --git a/packages/astro/src/runtime/server/escape.ts b/packages/astro/src/runtime/server/escape.ts index f499dca5b632..52c928186224 100644 --- a/packages/astro/src/runtime/server/escape.ts +++ b/packages/astro/src/runtime/server/escape.ts @@ -1,3 +1,34 @@ const entities = { '"': 'quot', '&': 'amp', "'": 'apos', '<': 'lt', '>': 'gt' } as const; -export const escapeHTML = (string: any) => string.replace(/["'&<>]/g, (char: keyof typeof entities) => '&' + entities[char] + ';'); +const warned = new Set(); +export const escapeHTML = (string: any, { deprecated = false }: { deprecated?: boolean } = {}) => { + const escaped = string.replace(/["'&<>]/g, (char: keyof typeof entities) => '&' + entities[char] + ';'); + if (!deprecated) return escaped; + if (warned.has(string) || !string.match(/[&<>]/g)) return string; + // eslint-disable-next-line no-console + console.warn(`Unescaped HTML content found inside expression! + +The next minor version of Astro will automatically escape all +expression content. Please use the \`set:html\` directive. + +Expression content: +${string}`); + warned.add(string); + + // Return unescaped content for now. To be removed. + return string; +} + +/** + * RawString is a "blessed" version of String + * that is not subject to escaping. + */ +export class UnescapedString extends String {} + +/** + * unescapeHTML marks a string as raw, unescaped HTML. + * This should only be generated internally, not a public API. + * + * Need to cast the return value `as unknown as string` so TS doesn't yell at us. + */ +export const unescapeHTML = (str: any) => new UnescapedString(str) as unknown as string; diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 46548b650b7e..ae4994ecde55 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -4,9 +4,11 @@ import type { AstroGlobalPartial, SSRResult, SSRElement } from '../../@types/ast import shorthash from 'shorthash'; import { extractDirectives, generateHydrateScript } from './hydration.js'; import { serializeListValue } from './util.js'; -export { createMetadata } from './metadata.js'; -export { escapeHTML } from './escape.js'; +import { escapeHTML, UnescapedString, unescapeHTML } from './escape.js'; + export type { Metadata } from './metadata'; +export { createMetadata } from './metadata.js'; +export { escapeHTML, unescapeHTML } from './escape.js'; const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; @@ -19,22 +21,24 @@ const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|l // Or maybe type UserValue = any; ? async function _render(child: any): Promise { child = await child; - if (Array.isArray(child)) { - return (await Promise.all(child.map((value) => _render(value)))).join(''); + if (child instanceof UnescapedString) { + return child; + } else if (Array.isArray(child)) { + return unescapeHTML((await Promise.all(child.map((value) => _render(value)))).join('')); } else if (typeof child === 'function') { // Special: If a child is a function, call it automatically. // This lets you do {() => ...} without the extra boilerplate // of wrapping it in a function and calling it. return _render(child()); } else if (typeof child === 'string') { - return child; + return escapeHTML(child, { deprecated: true }); } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. } // Add a comment explaining why each of these are needed. // Maybe create clearly named function for what this is doing. else if (child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]') { - return await renderAstroComponent(child); + return unescapeHTML(await renderAstroComponent(child)); } else { return child; } @@ -62,7 +66,7 @@ export class AstroComponent { const html = htmlParts[i]; const expression = expressions[i]; - yield _render(html); + yield _render(unescapeHTML(html)); yield _render(expression); } } @@ -88,7 +92,7 @@ export function createComponent(cb: AstroComponentFactory) { export async function renderSlot(_result: any, slotted: string, fallback?: any) { if (slotted) { - return _render(slotted); + return await _render(slotted); } return fallback; } @@ -122,12 +126,12 @@ export async function renderComponent(result: SSRResult, displayName: string, Co const children = await renderSlot(result, slots?.default); if (Component === Fragment) { - return children; + return unescapeHTML(children); } if (Component && (Component as any).isAstroComponentFactory) { const output = await renderToString(result, Component as any, _props, slots); - return output; + return unescapeHTML(output); } if (Component === null && !_props['client:only']) { @@ -233,7 +237,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr // as a string and the user is responsible for adding a script tag for the component definition. if (!html && typeof Component === 'string') { html = await renderAstroComponent( - await render`<${Component}${spreadAttributes(props)}${(children == null || children == '') && voidElementNames.test(Component) ? `/>` : `>${children}`}` + await render`<${Component}${spreadAttributes(props)}${unescapeHTML((children == null || children == '') && voidElementNames.test(Component) ? `/>` : `>${children}`)}` ); } @@ -248,7 +252,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr } if (!hydration) { - return html.replace(/\<\/?astro-fragment\>/g, ''); + return unescapeHTML(html.replace(/\<\/?astro-fragment\>/g, '')); } // Include componentExport name and componentUrl in hash to dedupe identical islands @@ -258,7 +262,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr // INVESTIGATE: This will likely be a problem in streaming because the `` will be gone at this point. result.scripts.add(await generateHydrateScript({ renderer, result, astroId, props }, metadata as Required)); - return `${html ?? ''}`; + return unescapeHTML(`${html ?? ''}`); } /** Create the Astro.fetchContent() runtime function. */ @@ -336,14 +340,14 @@ Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the // support "class" from an expression passed into an element (#782) if (key === 'class:list') { - return ` ${key.slice(0, -5)}="${toAttributeString(serializeListValue(value))}"`; + return unescapeHTML(` ${key.slice(0, -5)}="${toAttributeString(serializeListValue(value))}"`); } // Boolean only needs the key if (value === true && key.startsWith('data-')) { - return ` ${key}`; + return unescapeHTML(` ${key}`); } else { - return ` ${key}="${toAttributeString(value)}"`; + return unescapeHTML(` ${key}="${toAttributeString(value)}"`); } } @@ -353,7 +357,7 @@ export function spreadAttributes(values: Record) { for (const [key, value] of Object.entries(values)) { output += addAttribute(value, key); } - return output; + return unescapeHTML(output); } // Adds CSS variables to an inline style tag @@ -378,7 +382,7 @@ export function defineScriptVars(vars: Record) { export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any) { const Component = await componentFactory(result, props, children); let template = await renderAstroComponent(Component); - return template; + return unescapeHTML(template); } // Filter out duplicate elements in our set @@ -431,15 +435,15 @@ export async function renderPage(result: SSRResult, Component: AstroComponentFac } export async function renderAstroComponent(component: InstanceType) { - let template = ''; + let template = []; for await (const value of component) { if (value || value === 0) { - template += value; + template.push(value); } } - return template; + return unescapeHTML(await _render(template)); } export async function renderHTMLElement(result: SSRResult, constructor: typeof HTMLElement, props: any, slots: any) { diff --git a/yarn.lock b/yarn.lock index 1114ab731ef7..4e56bd93dd4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -130,10 +130,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@astrojs/compiler@^0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.10.0.tgz#6ba2707bf9a91017fd72fb46b1d7865036b71c09" - integrity sha512-TbeuITyhRGlQowipNX7Q8o5QczVjSxlE68xKh7i1swTxUhM8K/cnCPSyzTsWiuFWY4C5PDI1GREUaD3BHYfzqQ== +"@astrojs/compiler@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.10.1.tgz#69df1a7e4150c1b0b255154ae716dbc8b24c5dd4" + integrity sha512-SUp5auq6jcLmxyOx8Ovd3ebvwR5wnuqsbIi77Ze/ua3+GMcR++rOWiyqyql887U2ajZMwJfHatOwQl67P7o5gg== dependencies: typescript "^4.3.5"