repl-sdk 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repl-sdk",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -42,28 +42,28 @@
42
42
  "typescript": "^5.9.3",
43
43
  "vite": "^7.3.1",
44
44
  "vite-plugin-dts": "4.5.4",
45
- "vitest": "^3.2.4"
45
+ "vitest": "^4.0.18"
46
46
  },
47
47
  "dependencies": {
48
- "@codemirror/autocomplete": "6.20.0",
49
- "@codemirror/commands": "6.10.1",
48
+ "@codemirror/autocomplete": "^6.20.0",
49
+ "@codemirror/commands": "^6.10.1",
50
50
  "@codemirror/lang-html": "^6.4.11",
51
- "@codemirror/lang-javascript": "6.2.4",
52
- "@codemirror/lang-markdown": "6.5.0",
51
+ "@codemirror/lang-javascript": "^6.2.4",
52
+ "@codemirror/lang-markdown": "^6.5.0",
53
53
  "@codemirror/lang-vue": "^0.1.3",
54
54
  "@codemirror/lang-yaml": "^6.1.2",
55
55
  "@codemirror/language": "^6.12.1",
56
56
  "@codemirror/language-data": "^6.5.2",
57
- "@codemirror/lint": "^6.9.2",
58
- "@codemirror/search": "6.5.11",
59
- "@codemirror/state": "6.5.3",
60
- "@codemirror/view": "6.39.9",
57
+ "@codemirror/lint": "^6.9.3",
58
+ "@codemirror/search": "^6.6.0",
59
+ "@codemirror/state": "^6.5.4",
60
+ "@codemirror/view": "^6.39.12",
61
61
  "@lezer/common": "^1.5.0",
62
62
  "@lezer/highlight": "^1.2.3",
63
63
  "@lezer/html": "^1.3.13",
64
64
  "@lezer/markdown": "^1.6.3",
65
65
  "@replit/codemirror-lang-svelte": "^6.0.0",
66
- "@shikijs/rehype": "^3.7.0",
66
+ "@shikijs/rehype": "^3.22.0",
67
67
  "change-case": "^5.4.4",
68
68
  "codemirror": "^6.0.2",
69
69
  "codemirror-lang-mermaid": "^0.5.0",
@@ -73,7 +73,9 @@
73
73
  "mdast": "^3.0.0",
74
74
  "mime": "^4.0.7",
75
75
  "package-name-regex": "^5.0.0",
76
+ "rehype-autolink-headings": "^7.1.0",
76
77
  "rehype-raw": "^7.0.0",
78
+ "rehype-slug": "^6.0.0",
77
79
  "rehype-stringify": "^10.0.1",
78
80
  "remark-gfm": "^4.0.1",
79
81
  "remark-parse": "^11.0.0",
@@ -84,9 +86,9 @@
84
86
  "unified": "^11.0.5",
85
87
  "unist-util-visit": "^5.0.0",
86
88
  "vfile": "^6.0.3",
87
- "codemirror-lang-glimdown": "2.0.2",
88
- "codemirror-lang-glimmer-js": "2.0.2",
89
- "codemirror-lang-glimmer": "2.0.2"
89
+ "codemirror-lang-glimdown": "^2.0.3",
90
+ "codemirror-lang-glimmer-js": "^2.0.3",
91
+ "codemirror-lang-glimmer": "^2.0.3"
90
92
  },
91
93
  "volta": {
92
94
  "extends": "../../package.json"
@@ -40,6 +40,39 @@ export function buildCompiler(options) {
40
40
  });
41
41
  }
42
42
 
43
+ // Mark raw HTML components (PascalCase) before remarkRehype processes them
44
+ // @ts-ignore - unified processor types are complex and change as plugins are added
45
+ compiler = compiler.use(() => (tree) => {
46
+ visit(tree, 'html', function (node) {
47
+ // Check if this html node is a PascalCase component
48
+ if (typeof node.value === 'string' && node.value.match(/^<[A-Z][a-zA-Z0-9]/)) {
49
+ // Add a marker to the node's data that remarkRehype will preserve
50
+ // remark-rehype with allowDangerousHtml will turn this into a text node,
51
+ // and the data should be preserved
52
+ if (!node.data) node.data = {};
53
+ node.data.isPascalCaseComponent = true;
54
+ }
55
+ });
56
+
57
+ // After markinghtml nodes, also visit paragraphs to mark their children
58
+ visit(tree, 'paragraph', (paragraph) => {
59
+ if (paragraph.children) {
60
+ for (let i = 0; i < paragraph.children.length; i++) {
61
+ const child = paragraph.children[i];
62
+
63
+ if (
64
+ child.type === 'html' &&
65
+ typeof child.value === 'string' &&
66
+ child.value.match(/^<[A-Z][a-zA-Z0-9]/)
67
+ ) {
68
+ if (!child.data) child.data = {};
69
+ child.data.isPascalCaseComponent = true;
70
+ }
71
+ }
72
+ }
73
+ });
74
+ });
75
+
43
76
  // TODO: we only want to do this when we have pre > code.
44
77
  // code can exist inline.
45
78
  // @ts-ignore - unified processor types are complex and change as plugins are added
@@ -65,6 +98,10 @@ export function buildCompiler(options) {
65
98
  // However, it also changes all the nodes, so we need another pass
66
99
  // to make sure our Glimmer-aware nodes are in tact
67
100
  // @ts-ignore - unified processor types are complex and change as plugins are added
101
+ // remark rehype is needed to convert markdown to HTML
102
+ // However, it also changes all the nodes, so we need another pass
103
+ // to make sure our Glimmer-aware nodes are in tact
104
+ // @ts-ignore - unified processor types are complex and change as plugins are added
68
105
  compiler = compiler.use(remarkRehype, { allowDangerousHtml: true });
69
106
 
70
107
  // Convert invocables to raw format, so Glimmer can invoke them
@@ -81,6 +118,15 @@ export function buildCompiler(options) {
81
118
  return 'skip';
82
119
  }
83
120
 
121
+ // Check for PascalCase elements FIRST before checking for code elements
122
+ const tagName = /** @type {string | undefined} */ (nodeObj.tagName);
123
+
124
+ if (tagName && /^[A-Z]/.test(tagName)) {
125
+ nodeObj.type = 'glimmer_raw';
126
+
127
+ return 'skip';
128
+ }
129
+
84
130
  if (nodeObj.type === 'element' || ('tagName' in nodeObj && nodeObj.tagName === 'code')) {
85
131
  if (properties?.[/** @type {string} */ (/** @type {unknown} */ (GLIMDOWN_RENDER))]) {
86
132
  nodeObj.type = 'glimmer_raw';
@@ -91,19 +137,32 @@ export function buildCompiler(options) {
91
137
  return 'skip';
92
138
  }
93
139
 
94
- if (nodeObj.type === 'text' || nodeObj.type === 'raw') {
95
- // definitively not the better way, but this is supposed to detect "glimmer" nodes
140
+ // Check for nodes with values (text, raw, html, etc.)
141
+ if ('value' in nodeObj && typeof nodeObj.value === 'string') {
142
+ // Check if this raw node was marked as a PascalCase component in remark phase
143
+ const nodeData = /** @type {Record<string, unknown> | undefined} */ (
144
+ typeof node === 'object' && node !== null && 'data' in node ? node.data : undefined
145
+ );
146
+
147
+ if (nodeData?.isPascalCaseComponent) {
148
+ nodeObj.type = 'glimmer_raw';
149
+
150
+ return 'skip';
151
+ }
152
+
153
+ // Match raw PascalCase components in text that haven't been escaped yet
154
+ // Pattern: <PascalCaseName followed by whitespace, > or @
96
155
  if (
97
- 'value' in nodeObj &&
98
- typeof nodeObj.value === 'string' &&
99
- nodeObj.value.match(/<\/?[_A-Z:0-9].*>/g)
156
+ (nodeObj.type === 'text' || nodeObj.type === 'raw' || nodeObj.type === 'html') &&
157
+ nodeObj.value.match(/<[A-Z][a-zA-Z0-9]*(\s|>|@)/)
100
158
  ) {
101
159
  nodeObj.type = 'glimmer_raw';
102
- }
103
160
 
104
- nodeObj.type = 'glimmer_raw';
161
+ return 'skip';
162
+ }
105
163
 
106
- return 'skip';
164
+ // Let normal nodes be processed for escaping
165
+ return;
107
166
  }
108
167
 
109
168
  return;
@@ -1,22 +1,55 @@
1
1
  /**
2
- * @typedef {import('unified').Plugin} UPlugin
2
+ * @typedef {object} CodeBlock
3
+ * @property {string} lang
4
+ * @property {string} format
5
+ * @property {string} code
6
+ * @property {string} name
3
7
  */
8
+
9
+ /**
10
+ * @typedef {object} ParseResult
11
+ * @property {string} text
12
+ * @property {CodeBlock[]} codeBlocks
13
+ */
14
+
4
15
  import { buildCompiler } from './build-compiler.js';
5
16
 
6
17
  /**
7
18
  * @param {string} input
8
19
  * @param {import('./types').InternalOptions} options
9
- *
10
- * @returns {Promise<{ text: string; codeBlocks: { lang: string; format: string; code: string; name: string }[] }>}
20
+ * @returns {Promise<ParseResult>}
11
21
  */
12
22
  export async function parseMarkdown(input, options) {
13
23
  const markdownCompiler = buildCompiler(options);
14
24
  const processed = await markdownCompiler.process(input);
15
- const liveCode = /** @type {{ lang: string; format: string; code: string; name: string }[]} */ (
16
- processed.data.liveCode || []
17
- );
25
+ const liveCode = /** @type {CodeBlock[]} */ (processed.data.liveCode || []);
18
26
  // @ts-ignore - processed is typed as unknown due to unified processor complexity
19
- const templateOnly = processed.toString();
27
+ let templateOnly = processed.toString();
28
+
29
+ // Unescape PascalCase components that had only the opening < HTML-entity escaped
30
+ // BUT only outside of <pre><code> blocks where escaping should be preserved
31
+ // (inline <code> tags should have components unescaped)
32
+ // Split by <pre><code>...</code></pre> to exclude only code blocks
33
+ const parts = templateOnly.split(/(<pre[^>]*>.*?<\/pre>)/is);
34
+
35
+ for (let i = 0; i < parts.length; i++) {
36
+ const part = parts[i];
37
+
38
+ // Only process parts that are NOT pre blocks (odd indices are pre blocks)
39
+ if (i % 2 === 0 && part) {
40
+ // Pattern: &#x3C;ComponentName ... / > (only < is escaped as &#x3C;)
41
+ parts[i] = part.replace(/&#x3C;([A-Z][a-zA-Z0-9]*\s[^<]*?)>/g, (match, content) => {
42
+ // Only unescape if it contains @ (attribute) indicating a component
43
+ if (content.includes('@')) {
44
+ return `<${content}>`;
45
+ }
46
+
47
+ return match;
48
+ });
49
+ }
50
+ }
51
+
52
+ templateOnly = parts.join('');
20
53
 
21
54
  return { text: templateOnly, codeBlocks: liveCode };
22
55
  }
@@ -1,8 +1,9 @@
1
1
  import rehypeShiki from '@shikijs/rehype';
2
2
  import { stripIndent } from 'common-tags';
3
3
  import { visit } from 'unist-util-visit';
4
- import { describe, expect as errorExpect, it } from 'vitest';
4
+ import { beforeEach, describe, expect as errorExpect, it } from 'vitest';
5
5
 
6
+ import { resetIdCounter } from '../../utils.js';
6
7
  import { parseMarkdown } from './parse.js';
7
8
  import { buildCodeFenceMetaUtils } from './utils.js';
8
9
 
@@ -41,6 +42,174 @@ const defaults = {
41
42
  ALLOWED_FORMATS,
42
43
  };
43
44
 
45
+ beforeEach(() => {
46
+ resetIdCounter();
47
+ });
48
+
49
+ describe('default features', () => {
50
+ it('allows one-line-component invocation', { timeout: 10_000 }, async () => {
51
+ const result = await parseMarkdown(`<APIDocs @package="ember-primitives" @name="Avatar" />`, {
52
+ ...defaults,
53
+ rehypePlugins: [[rehypeShiki, { theme: 'github-dark' }]],
54
+ });
55
+
56
+ expect(result.codeBlocks).toMatchInlineSnapshot(`[]`);
57
+ expect(result.text).toMatchInlineSnapshot(
58
+ `"<p><APIDocs @package="ember-primitives" @name="Avatar" /></p>"`
59
+ );
60
+ });
61
+
62
+ it('allows multi-line-component invocation', async () => {
63
+ const result = await parseMarkdown(
64
+ [`<APIDocs`, `@package="ember-primitives"`, `@name="Avatar"`, `/>`].join('\n'),
65
+ {
66
+ ...defaults,
67
+ rehypePlugins: [[rehypeShiki, { theme: 'github-dark' }]],
68
+ }
69
+ );
70
+
71
+ expect(result.codeBlocks).toMatchInlineSnapshot(`[]`);
72
+ expect(result.text).toMatchInlineSnapshot(`
73
+ "<p><APIDocs
74
+ @package="ember-primitives"
75
+ @name="Avatar"
76
+ /></p>"
77
+ `);
78
+ });
79
+
80
+ it('allows components inside code blocks', async () => {
81
+ const result = await parseMarkdown(
82
+ [
83
+ '# Hello',
84
+ '',
85
+ '<code>',
86
+ ' <Shadowed @includeStyles={{true}}>',
87
+ ' the shadow realm',
88
+ ' </Shadowed>',
89
+ '</code>',
90
+ ].join('\n'),
91
+ {
92
+ ...defaults,
93
+ }
94
+ );
95
+
96
+ expect(result.codeBlocks).toMatchInlineSnapshot(`[]`);
97
+ expect(result.text).toMatchInlineSnapshot(`
98
+ "<h1 id="hello">Hello</h1>
99
+ <code>
100
+ <Shadowed @includeStyles={{true}}>
101
+ the shadow realm
102
+ </Shadowed>
103
+ </code>"
104
+ `);
105
+ });
106
+
107
+ describe('does not', () => {
108
+ it(`mistakenly transform text in codefences (previewed text is transformed though)`, async () => {
109
+ const result = await parseMarkdown(
110
+ [
111
+ '# Hello',
112
+ '',
113
+ '```gjs live preview',
114
+ "import { Shadowed, PortalTargets } from 'ember-primitives';",
115
+ '',
116
+ '<template>',
117
+ ' <Shadowed @includeStyles={{true}}>',
118
+ ' the shadow realm',
119
+ ' </Shadowed>',
120
+ '</template>',
121
+ ].join('\n'),
122
+ {
123
+ ...defaults,
124
+ }
125
+ );
126
+
127
+ expect(result.codeBlocks).toMatchInlineSnapshot(`
128
+ [
129
+ {
130
+ "code": "import { Shadowed, PortalTargets } from 'ember-primitives';
131
+
132
+ <template>
133
+ <Shadowed @includeStyles={{true}}>
134
+ the shadow realm
135
+ </Shadowed>
136
+ </template>",
137
+ "flavor": undefined,
138
+ "format": "gjs",
139
+ "meta": "live preview",
140
+ "placeholderId": "repl_1",
141
+ },
142
+ ]
143
+ `);
144
+ expect(result.text).toMatchInlineSnapshot(`
145
+ "<h1 id="hello">Hello</h1>
146
+ <div id="repl_1" class="repl-sdk__demo"></div>
147
+ <div class="repl-sdk__snippet" data-repl-output><pre><code class="language-gjs">import { Shadowed, PortalTargets } from 'ember-primitives';
148
+
149
+ &#x3C;template>
150
+ &#x3C;Shadowed @includeStyles=\\{{true}}>
151
+ the shadow realm
152
+ &#x3C;/Shadowed>
153
+ &#x3C;/template>
154
+ </code></pre></div>"
155
+ `);
156
+ });
157
+
158
+ it(`mistakenly transform text in codefences`, async () => {
159
+ const result = await parseMarkdown(
160
+ [
161
+ '# Hello',
162
+ '',
163
+ '```gjs live',
164
+ 'import { Hello } from "somewhere";',
165
+ '',
166
+ '<template>',
167
+ ' <Hello />',
168
+ '</template>',
169
+ ].join('\n'),
170
+ {
171
+ ...defaults,
172
+ rehypePlugins: [[rehypeShiki, { theme: 'github-dark' }]],
173
+ }
174
+ );
175
+
176
+ expect(result.codeBlocks).toMatchInlineSnapshot(`
177
+ [
178
+ {
179
+ "code": "import { Hello } from "somewhere";
180
+
181
+ <template>
182
+ <Hello />
183
+ </template>",
184
+ "flavor": undefined,
185
+ "format": "gjs",
186
+ "meta": "live",
187
+ "placeholderId": "repl_1",
188
+ },
189
+ ]
190
+ `);
191
+ expect(result.text).toMatchInlineSnapshot(`
192
+ "<h1 id="hello">Hello</h1>
193
+ <div id="repl_1" class="repl-sdk__demo"></div>"
194
+ `);
195
+ });
196
+
197
+ it('mistakenly transform a component text in backticks', async () => {
198
+ const result = await parseMarkdown('## `<Hello @foo="two" />`', {
199
+ ...defaults,
200
+ rehypePlugins: [[rehypeShiki, { theme: 'github-dark' }]],
201
+ });
202
+
203
+ expect(result).toMatchInlineSnapshot(`
204
+ {
205
+ "codeBlocks": [],
206
+ "text": "<h2 id="hello-foo-two"><code><Hello @foo="two" /></code></h2>",
207
+ }
208
+ `);
209
+ });
210
+ });
211
+ });
212
+
44
213
  describe('options', () => {
45
214
  describe('remarkPlugins', () => {
46
215
  it('works', async () => {
@@ -233,7 +402,7 @@ describe('options', () => {
233
402
 
234
403
  expect(result.text).toMatchInlineSnapshot(`
235
404
  "<h1 id="title">Title</h1>
236
- <div id="repl_2" class="repl-sdk__demo"></div>"
405
+ <div id="repl_1" class="repl-sdk__demo"></div>"
237
406
  `);
238
407
 
239
408
  assertCodeBlocks(result.codeBlocks, [
@@ -311,7 +480,7 @@ describe('options', () => {
311
480
 
312
481
  expect(result.text).toMatchInlineSnapshot(`
313
482
  "<h1 id="title">Title</h1>
314
- <div id="repl_3" class="repl-sdk__demo"></div>
483
+ <div id="repl_1" class="repl-sdk__demo"></div>
315
484
  <Demo />"
316
485
  `);
317
486
 
@@ -340,7 +509,7 @@ describe('options', () => {
340
509
 
341
510
  expect(result.text).toMatchInlineSnapshot(`
342
511
  "<p>hi</p>
343
- <div id="repl_4" class="repl-sdk__demo"></div>
512
+ <div id="repl_1" class="repl-sdk__demo"></div>
344
513
  <div class="repl-sdk__snippet" data-repl-output><pre><code class="language-gjs">import Component from '@glimmer/component';
345
514
  import { on } from '@ember/modifier';
346
515
 
package/src/utils.js CHANGED
@@ -17,6 +17,10 @@ export function nextId() {
17
17
  return `repl_${i}`;
18
18
  }
19
19
 
20
+ export function resetIdCounter() {
21
+ i = 0;
22
+ }
23
+
20
24
  export const fakeDomain = 'repl.sdk';
21
25
  export const tgzPrefix = 'file:///tgz.repl.sdk/';
22
26
  export const unzippedPrefix = 'file:///tgz.repl.sdk/unzipped';