From 3cb1179a212eaad2d498d868411aca72f850b5d2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 10 Mar 2024 23:31:28 -0400 Subject: [PATCH] Escape {{ after the rehype phase instead of during the remark phase (#1704) --- .../addon/src/compile/formats/markdown.ts | 45 +++++------ packages/ember-repl/test-app/app/app.ts | 3 +- .../ember-repl/test-app/ember-cli-build.js | 1 + packages/ember-repl/test-app/package.json | 2 + .../test-app/tests/unit/markdown-test.ts | 81 ++++++++++++++++--- pnpm-lock.yaml | 39 +++++++++ 6 files changed, 132 insertions(+), 39 deletions(-) diff --git a/packages/ember-repl/addon/src/compile/formats/markdown.ts b/packages/ember-repl/addon/src/compile/formats/markdown.ts index 68b6065da..240ead839 100644 --- a/packages/ember-repl/addon/src/compile/formats/markdown.ts +++ b/packages/ember-repl/addon/src/compile/formats/markdown.ts @@ -49,26 +49,6 @@ const ALLOWED_LANGUAGES = ['gjs', 'hbs'] as const; type AllowedLanguage = (typeof ALLOWED_LANGUAGES)[number]; type RelevantCode = Omit & { lang: AllowedLanguage }; -const escapeCurlies = (node: Text | Parent) => { - if ('value' in node && node.value) { - node.value = node.value.replace(/{{/g, '\\{{'); - } - - if ('children' in node && node.children) { - node.children.forEach((child) => escapeCurlies(child as Parent)); - } - - if (!node.data) { - return; - } - - if ('hChildren' in node.data && Array.isArray(node.data['hChildren'])) { - node.data['hChildren'].forEach(escapeCurlies); - - return; - } -}; - function isLive(meta: string) { return meta.includes('live'); } @@ -218,6 +198,24 @@ function liveCodeExtraction(options: Options = {}) { }; } +function sanitizeForGlimmer(/* options */) { + return (tree: Parent) => { + visit(tree, 'element', (node: Parent) => { + if ('tagName' in node) { + if (node.tagName !== 'pre') return; + + visit(node, 'text', (textNode: Text) => { + if ('value' in textNode && textNode.value) { + textNode.value = textNode.value.replace(/{{/g, '\\{{'); + } + }); + + return 'skip'; + } + }); + }; +} + function buildCompiler(options: ParseMarkdownOptions) { let compiler = unified().use(remarkParse).use(remarkGfm); @@ -261,9 +259,6 @@ function buildCompiler(options: ParseMarkdownOptions) { let properties = (node as any).properties; if (properties?.[GLIMDOWN_PREVIEW]) { - // Have to sanitize anything Glimmer could try to render - escapeCurlies(node as Parent); - return 'skip'; } @@ -274,8 +269,6 @@ function buildCompiler(options: ParseMarkdownOptions) { return; } - escapeCurlies(node as Parent); - return 'skip'; } @@ -314,6 +307,8 @@ function buildCompiler(options: ParseMarkdownOptions) { }); }); + compiler = compiler.use(sanitizeForGlimmer); + // Finally convert to string! oofta! compiler = compiler.use(rehypeStringify, { collapseEmptyAttributes: true, diff --git a/packages/ember-repl/test-app/app/app.ts b/packages/ember-repl/test-app/app/app.ts index 3c71fc5d8..a353fd541 100644 --- a/packages/ember-repl/test-app/app/app.ts +++ b/packages/ember-repl/test-app/app/app.ts @@ -10,7 +10,8 @@ import config from 'test-app/config/environment'; // But they aren't used.... so.. that's fun. Object.assign(window, { process: { env: {} }, - Buffer: {}, + // Polyfilled in webpack + // Buffer: {}, }); export default class App extends Application { diff --git a/packages/ember-repl/test-app/ember-cli-build.js b/packages/ember-repl/test-app/ember-cli-build.js index c703ccf13..e5c22a8a3 100644 --- a/packages/ember-repl/test-app/ember-cli-build.js +++ b/packages/ember-repl/test-app/ember-cli-build.js @@ -51,6 +51,7 @@ module.exports = function (defaults) { resolve: { fallback: { path: 'path-browserify', + buffer: require.resolve('buffer/'), }, }, }, diff --git a/packages/ember-repl/test-app/package.json b/packages/ember-repl/test-app/package.json index f47aac044..e6bd50316 100644 --- a/packages/ember-repl/test-app/package.json +++ b/packages/ember-repl/test-app/package.json @@ -25,7 +25,9 @@ "lint:prettier": "pnpm -w exec lint prettier" }, "dependencies": { + "@shikijs/rehype": "^1.1.7", "@types/unist": "^3.0.2", + "buffer": "^6.0.3", "common-tags": "^1.8.2", "ember-repl": "workspace:*", "ember-resources": "^7.0.0", diff --git a/packages/ember-repl/test-app/tests/unit/markdown-test.ts b/packages/ember-repl/test-app/tests/unit/markdown-test.ts index d23e3e65e..a2e4f9f65 100644 --- a/packages/ember-repl/test-app/tests/unit/markdown-test.ts +++ b/packages/ember-repl/test-app/tests/unit/markdown-test.ts @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; +import rehypeShiki from '@shikijs/rehype'; import { stripIndent } from 'common-tags'; import { invocationOf, nameFor } from 'ember-repl'; import { parseMarkdown } from 'ember-repl/formats/markdown'; @@ -10,13 +11,7 @@ import { visit } from 'unist-util-visit'; * indentation are stripped */ function assertOutput(actual: string, expected: string) { - let _actual = actual - .split('\n') - .filter(Boolean) - .join('\n') - .trim() - .replace(/
/, '') - .replace(/<\/div>/, ''); + let _actual = actual.split('\n').filter(Boolean).join('\n').trim(); let _expected = expected.split('\n').filter(Boolean).join('\n').trim(); QUnit.assert.equal(_actual, _expected); @@ -60,7 +55,7 @@ module('Unit | parseMarkdown()', function () {

Title

  const two = 2;
-        
+
` ); @@ -178,6 +173,38 @@ module('Unit | parseMarkdown()', function () { assert.deepEqual(result.blocks, []); }); + + test('rehypePlugins retain {{ }} escaping', async function () { + let result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs + const two = 2 + + + \`\`\` + `, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rehypePlugins: [[rehypeShiki as any, { theme: 'github-dark' }]], + } + ); + + assertOutput( + result.templateOnlyGlimdown, + `

Title

+
const two = 2
+
+<template>
+  \\{{two}}
+</template>
+
+ ` + ); + }); }); module('hbs', function () { @@ -199,7 +226,7 @@ module('Unit | parseMarkdown()', function () { stripIndent`

Title

- ${invocationOf(name)} +
${invocationOf(name)}
` ); @@ -232,7 +259,7 @@ module('Unit | parseMarkdown()', function () {

Title

  const two = 2;
-          
+
` ); @@ -255,7 +282,7 @@ module('Unit | parseMarkdown()', function () { stripIndent`

Title

- ${invocationOf(name)} +
${invocationOf(name)}
` ); @@ -268,6 +295,34 @@ module('Unit | parseMarkdown()', function () { ]); }); + test('Code with preview fence has {{ }} tokens escaped', async function () { + let result = await parseMarkdown(stripIndent` + # Title + + \`\`\`gjs + const two = 2 + + + \`\`\` + `); + + assertOutput( + result.templateOnlyGlimdown, + stripIndent` +

Title

+ +
const two = 2
+
+          <template>
+            \\{{two}}
+          </template>
+          
+ ` + ); + }); + test('Can invoke a component again when defined in a live fence', async function (assert) { let snippet = `const two = 2`; let name = nameFor(snippet); @@ -285,7 +340,7 @@ module('Unit | parseMarkdown()', function () { stripIndent`

Title

- ${invocationOf(name)} +
${invocationOf(name)}
` ); @@ -319,7 +374,7 @@ module('Unit | parseMarkdown()', function () { stripIndent`

hi

- ${invocationOf(name)} +
${invocationOf(name)}
import Component from '@glimmer/component';
           import { on } from '@ember/modifier';
           <template>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d907d2646..7dfd869c3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1541,9 +1541,15 @@ importers:
 
   packages/ember-repl/test-app:
     dependencies:
+      '@shikijs/rehype':
+        specifier: ^1.1.7
+        version: 1.1.7
       '@types/unist':
         specifier: ^3.0.2
         version: 3.0.2
+      buffer:
+        specifier: ^6.0.3
+        version: 6.0.3
       common-tags:
         specifier: ^1.8.2
         version: 1.8.2
@@ -7108,6 +7114,27 @@ packages:
     requiresBuild: true
     optional: true
 
+  /@shikijs/core@1.1.7:
+    resolution: {integrity: sha512-gTYLUIuD1UbZp/11qozD3fWpUTuMqPSf3svDMMrL0UmlGU7D9dPw/V1FonwAorCUJBltaaESxq90jrSjQyGixg==}
+    dev: false
+
+  /@shikijs/rehype@1.1.7:
+    resolution: {integrity: sha512-gNNmzQjcosjCAoUBrW6DzCmp6XdUQk4RNmyLsqMaDVpGWQsQCkjMBnBLQ7xWnjq6lwQAlq+m1/19kImsUJnpkg==}
+    dependencies:
+      '@shikijs/transformers': 1.1.7
+      '@types/hast': 3.0.4
+      hast-util-to-string: 3.0.0
+      shiki: 1.1.7
+      unified: 11.0.4
+      unist-util-visit: 5.0.0
+    dev: false
+
+  /@shikijs/transformers@1.1.7:
+    resolution: {integrity: sha512-lXz011ao4+rvweps/9h3CchBfzb1U5OtP5D51Tqc9lQYdLblWMIxQxH6Ybe1GeGINcEVM4goMyPrI0JvlIp4UQ==}
+    dependencies:
+      shiki: 1.1.7
+    dev: false
+
   /@sigstore/bundle@2.2.0:
     resolution: {integrity: sha512-5VI58qgNs76RDrwXNhpmyN/jKpq9evV/7f1XrcqcAfvxDl5SeVY/I5Rmfe96ULAV7/FK5dge9RBKGBJPhL1WsQ==}
     engines: {node: ^16.14.0 || >=18.0.0}
@@ -15150,6 +15177,12 @@ packages:
       zwitch: 2.0.4
     dev: false
 
+  /hast-util-to-string@3.0.0:
+    resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==}
+    dependencies:
+      '@types/hast': 3.0.4
+    dev: false
+
   /hast-util-whitespace@2.0.1:
     resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==}
     dev: false
@@ -20167,6 +20200,12 @@ packages:
     resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==}
     dev: true
 
+  /shiki@1.1.7:
+    resolution: {integrity: sha512-9kUTMjZtcPH3i7vHunA6EraTPpPOITYTdA5uMrvsJRexktqP0s7P3s9HVK80b4pP42FRVe03D7fT3NmJv2yYhw==}
+    dependencies:
+      '@shikijs/core': 1.1.7
+    dev: false
+
   /side-channel@1.0.6:
     resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
     engines: {node: '>= 0.4'}