@ape-egg/vibe 2.1.3 → 2.1.6

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.
@@ -190,7 +190,7 @@ impl HtmlParser {
190
190
  // Find all tags that match loaded elements
191
191
  for (tag_name, element) in &self.cache {
192
192
  // Match opening and closing tags with any attributes and children
193
- let tag_pattern = format!(r"<{}(\s[^>]*)?>", regex::escape(tag_name));
193
+ let tag_pattern = format!(r"<{}(\s{})?>", regex::escape(tag_name), ATTR_RUN);
194
194
  let tag_re = regex::Regex::new(&tag_pattern).unwrap();
195
195
  let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
196
196
 
@@ -261,7 +261,11 @@ impl HtmlParser {
261
261
  fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
262
262
  // (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
263
263
  let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
264
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
264
+ // `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
265
+ // it, and whitespace-controlled markup splits end tags across lines
266
+ // (`</component\n>`). `\s*` can't bridge into `</component-foo>` (the `-`
267
+ // is neither whitespace nor `>`), so this stays exact on the tag name.
268
+ let close_re = regex::Regex::new(&format!(r"</{}\s*>", regex::escape(tag_name))).unwrap();
265
269
 
266
270
  let mut depth = 1i32;
267
271
  let mut cursor = after_open;
@@ -332,7 +336,7 @@ impl HtmlParser {
332
336
 
333
337
  // Match only the opening tag — slot content is extracted via depth-counting close search
334
338
  // to correctly handle slot content that contains </div> or nested <component> elements.
335
- let pattern = format!(r#"<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>"#, regex::escape(&normalized_src));
339
+ let pattern = format!(r#"<(component|div)\s+({attr})\bsrc="{src}"\s*({attr})>"#, attr = ATTR_RUN, src = regex::escape(&normalized_src));
336
340
  let open_re = regex::Regex::new(&pattern).unwrap();
337
341
 
338
342
  let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
@@ -389,7 +393,7 @@ impl HtmlParser {
389
393
  // Match only the opening component tag — slot content is extracted via depth-counting
390
394
  // to correctly handle slot content containing </div> or nested <component> elements.
391
395
  let open_re = regex::Regex::new(
392
- r#"<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>"#
396
+ &format!(r#"<(component|div)\s+({attr})\bsrc="([^"]+)"({attr})>"#, attr = ATTR_RUN)
393
397
  ).unwrap();
394
398
 
395
399
  // Process one match at a time with re-scanning after each replacement.
@@ -499,6 +503,13 @@ fn neuter_component_scripts(html: &str) -> String {
499
503
  html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
500
504
  }
501
505
 
506
+ /// A run of HTML attributes where `>` is permitted only inside a quoted value.
507
+ /// Mirrors a real HTML tokenizer: a tag ends on an *unquoted* `>` only. Without
508
+ /// this, a binding prop like `flipped="@[a >= b]"` truncates the open tag at the
509
+ /// `>` inside its value, so the trailing attributes get mis-parsed (empty
510
+ /// boolean props → literal `true`) and corrupt the inlined template.
511
+ const ATTR_RUN: &str = r#"(?:"[^"]*"|'[^']*'|[^>"'])*"#;
512
+
502
513
  fn is_ident_byte(b: u8) -> bool {
503
514
  b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
504
515
  }
@@ -744,3 +755,102 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
744
755
 
745
756
  result
746
757
  }
758
+
759
+ #[cfg(test)]
760
+ mod tests {
761
+ use super::*;
762
+
763
+ #[test]
764
+ fn find_matching_close_tolerates_whitespace_in_end_tag() {
765
+ // Whitespace-controlled markup splits an end tag across lines, e.g.
766
+ // <icon-cell
767
+ // ><component src="x"></component
768
+ // ></icon-cell>
769
+ // which reaches the inliner as `</component\n>`. HTML permits
770
+ // whitespace before the `>` of an end tag, so the depth counter must
771
+ // treat `</component\n>` as the nested close. If it doesn't, the nested
772
+ // open is counted but never closed and the OUTER close search overshoots,
773
+ // swallowing every following sibling into the component.
774
+ let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
775
+ let after_open = "<component>".len();
776
+
777
+ let close = HtmlParser::find_matching_close(content, after_open, "component")
778
+ .expect("outer </component> must be found");
779
+
780
+ // The match must be the OUTER close (immediately before <sibling>), not
781
+ // an overshoot past it.
782
+ assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
783
+ assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
784
+ }
785
+
786
+ #[test]
787
+ fn inline_component_tolerates_gt_in_prop_binding() {
788
+ // A component prop whose `@[...]` value contains a comparison operator
789
+ // (`>=`) must not truncate the open tag at the `>` *inside the value*.
790
+ // The flawed `[^>]*` attribute run cut the tag mid-attribute, so the
791
+ // trailing identifiers (`selectedBrawlers`, `length`) were mis-parsed as
792
+ // empty boolean props (→ literal `true`), rewriting the loop expression
793
+ // to `brawlHandCards(characters, true)` — which throws `true.includes is
794
+ // not a function` at runtime, so the card discs never render.
795
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
796
+ let page = concat!(
797
+ r#"<page><component src="/components/remote/CardHand.html" "#,
798
+ r#"cards="@[brawlHandCards(characters, selectedBrawlers)]" "#,
799
+ r#"flipped="@[selectedBrawlers.length >= maxBrawlers]" "#,
800
+ r#"angle="16"></component></page>"#,
801
+ );
802
+ let template = concat!(
803
+ r#"<card-hand flipped="@[flipped]"><card-fan style="--angle: @[angle]deg">"#,
804
+ r#"<!-- each cards as card (card.id), i --><card-slot data-id="@[card.id]"></card-slot>"#,
805
+ r#"<!-- /each --></card-fan></card-hand>"#,
806
+ );
807
+ let mut cache = HashMap::new();
808
+ cache.insert("/components/remote/CardHand.html".to_string(), template.to_string());
809
+
810
+ let out = parser.inline_component_elements(page, &cache);
811
+
812
+ // The loop expression must carry `selectedBrawlers` through untouched.
813
+ assert!(
814
+ out.contains("brawlHandCards(characters, selectedBrawlers)"),
815
+ "loop expression corrupted: {out}"
816
+ );
817
+ assert!(
818
+ !out.contains("brawlHandCards(characters, true)"),
819
+ "selectedBrawlers wrongly replaced with `true`: {out}"
820
+ );
821
+ // The binding prop keeps its full comparison expression.
822
+ assert!(
823
+ out.contains(r#"flipped="@[selectedBrawlers.length >= maxBrawlers]""#),
824
+ "flipped binding lost: {out}"
825
+ );
826
+ // A literal prop *after* the `>=` prop must still substitute — proof the
827
+ // tag was parsed to the real `>`, not the one inside the value.
828
+ assert!(out.contains("--angle: 16deg"), "angle not substituted: {out}");
829
+ }
830
+
831
+ #[test]
832
+ fn inline_custom_element_tolerates_gt_in_prop_binding() {
833
+ // Same defect in the custom-element inliner's `<tag(\s[^>]*)?>` regex.
834
+ // (Cached element tags are filename-derived single words — see element.rs.)
835
+ let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
836
+ parser.cache.insert(
837
+ "gauge".to_string(),
838
+ Element::new(
839
+ "gauge".to_string(),
840
+ std::path::PathBuf::from("gauge.html"),
841
+ r#"<gauge-inner show="@[show]" n="@[count]"></gauge-inner>"#.to_string(),
842
+ ),
843
+ );
844
+
845
+ let page = r#"<page><gauge show="@[items.length >= max]" count="7"></gauge></page>"#;
846
+ let out = parser.inline_custom_elements(page);
847
+
848
+ // The `>=` inside the binding must not have truncated the open tag.
849
+ assert!(
850
+ out.contains(r#"show="@[items.length >= max]""#),
851
+ "show binding lost: {out}"
852
+ );
853
+ // A literal prop after the `>=` prop still substitutes → tag parsed fully.
854
+ assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
855
+ }
856
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.3",
3
+ "version": "2.1.6",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -126,6 +126,98 @@ export const buildHyperspeedManifest = (parsedTree) => {
126
126
  let hyperspeedData = null;
127
127
  let hyperspeedDetectionAttempted = false;
128
128
 
129
+ /**
130
+ * Build the ordered list of manifest URLs to try for a page, most-likely first.
131
+ *
132
+ * `pathname` is window.location.pathname; `route` is the optional route template
133
+ * the page declares (window.__ROUTE__, e.g. "/brawlers/:index"). When the route
134
+ * marks a segment dynamic with `:param`, the compiler has collapsed that segment
135
+ * to `$` in the manifest path — so we point straight at the tokenized manifest
136
+ * instead of probing literal paths (`/brawlers/0.html.manifest.js`) that are
137
+ * guaranteed to 404. Without the route hint the original literal-first strategies
138
+ * apply unchanged. The result is de-duplicated (subdirectory pages otherwise
139
+ * produce the same candidate twice).
140
+ *
141
+ * @param {string} pathname
142
+ * @param {string|null|undefined} route
143
+ * @returns {string[]}
144
+ */
145
+ export const buildManifestCandidatePaths = (pathname, route) => {
146
+ let pagePath = pathname;
147
+
148
+ // Normalize path: handle directory URLs and missing extensions
149
+ if (pagePath.endsWith("/")) {
150
+ pagePath = pagePath + "index.html";
151
+ } else if (!pagePath.includes(".")) {
152
+ const lastSlash = pagePath.lastIndexOf("/");
153
+ const lastSegment = pagePath.substring(lastSlash + 1);
154
+ if (lastSegment && !lastSegment.includes(".")) {
155
+ pagePath = pagePath + ".html";
156
+ }
157
+ }
158
+
159
+ const pathSegments = pagePath.split("/").filter((s) => s);
160
+ if (pathSegments.length === 0) return [];
161
+
162
+ const fileName = pathSegments[pathSegments.length - 1];
163
+ const dirSegments = pathSegments.slice(0, -1);
164
+
165
+ const possiblePaths = [];
166
+
167
+ // Route-aware fast path: a `:segment` in the declared route is a dynamic param
168
+ // the compiler tokenized to `$`. Tokenize exactly those positions (params can
169
+ // sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
170
+ // 404 probing. Skipped entirely when no route is declared.
171
+ const routeSegments = route ? route.split("/").filter((s) => s) : null;
172
+ if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
173
+ const tokenized = pathSegments.map((seg, i) => {
174
+ if (!routeSegments[i]?.startsWith(":")) return seg;
175
+ const dot = seg.indexOf(".");
176
+ return dot >= 0 ? "$" + seg.slice(dot) : "$";
177
+ });
178
+ possiblePaths.push(`/vibe-hyperspeed/${tokenized.join("/")}.manifest.js`);
179
+ }
180
+
181
+ // Strategy 1: vibe-hyperspeed at the same level as parent directory
182
+ // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
183
+ if (dirSegments.length >= 1) {
184
+ const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
185
+ const baseDir = "/" + dirSegments[0]; // First directory segment
186
+ possiblePaths.push(
187
+ `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
188
+ );
189
+ }
190
+
191
+ // Strategy 2: vibe-hyperspeed at web root (original behavior)
192
+ // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
193
+ possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
194
+
195
+ // Strategy 3: vibe-hyperspeed relative to immediate parent
196
+ // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
197
+ if (dirSegments.length > 0) {
198
+ const relativePath = dirSegments.join("/");
199
+ possiblePaths.push(
200
+ `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
201
+ );
202
+ }
203
+
204
+ // Strategy 4: dynamic routes without a declared route. The compiler collapses a
205
+ // `$param` segment to a single `$` token (the-arena/$id.html ->
206
+ // the-arena/$.html.manifest.js), so a concrete URL only matches once its
207
+ // trailing segment is tokenized. Tried after the literal strategies, so static
208
+ // pages still win on an exact hit.
209
+ const dot = fileName.indexOf(".");
210
+ const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
211
+ if (tokenized !== fileName) {
212
+ const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
213
+ possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
214
+ }
215
+
216
+ // Subdirectory pages make strategies 2 and 3 collapse to the same URL — probe
217
+ // each candidate once.
218
+ return [...new Set(possiblePaths)];
219
+ };
220
+
129
221
  /**
130
222
  * Detect page-specific manifest (async, cached after first call)
131
223
  * Returns { manifest, path } or null
@@ -143,66 +235,15 @@ const detectHyperspeed = async () => {
143
235
  const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
144
236
 
145
237
  try {
146
- let pagePath = window.location.pathname;
147
-
148
- // Normalize path: handle directory URLs and missing extensions
149
- if (pagePath.endsWith("/")) {
150
- // /compiled/ -> /compiled/index.html
151
- pagePath = pagePath + "index.html";
152
- } else if (!pagePath.includes(".")) {
153
- // /compiled/mypage -> /compiled/mypage.html
154
- const lastSlash = pagePath.lastIndexOf("/");
155
- const lastSegment = pagePath.substring(lastSlash + 1);
156
- if (lastSegment && !lastSegment.includes(".")) {
157
- pagePath = pagePath + ".html";
158
- }
159
- }
160
-
161
- const pathSegments = pagePath.split("/").filter((s) => s);
162
-
163
- if (pathSegments.length === 0) return null;
164
-
165
- // Extract file name and directory parts
166
- // For /compiled/playground/test.html -> ['compiled', 'playground', 'test.html']
167
- const fileName = pathSegments[pathSegments.length - 1];
168
- const dirSegments = pathSegments.slice(0, -1); // All parts except filename
169
-
170
- // Build possible manifest paths
171
- const possiblePaths = [];
172
-
173
- // Strategy 1: vibe-hyperspeed at the same level as parent directory
174
- // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
175
- if (dirSegments.length >= 1) {
176
- const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
177
- const baseDir = "/" + dirSegments[0]; // First directory segment
178
- possiblePaths.push(
179
- `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
180
- );
181
- }
182
-
183
- // Strategy 2: vibe-hyperspeed at web root (original behavior)
184
- // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
185
- possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
186
-
187
- // Strategy 3: vibe-hyperspeed relative to immediate parent
188
- // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
189
- if (dirSegments.length > 0) {
190
- const relativePath = dirSegments.join("/");
191
- possiblePaths.push(
192
- `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
193
- );
194
- }
195
-
196
- // Strategy 4: dynamic routes. The compiler collapses a `$param` segment to a
197
- // single `$` token (the-arena/$id.html -> the-arena/$.html.manifest.js), so a
198
- // concrete URL only matches once its trailing segment is tokenized. Tried
199
- // after the literal strategies, so static pages still win on an exact hit.
200
- const dot = fileName.indexOf(".");
201
- const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
202
- if (tokenized !== fileName) {
203
- const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
204
- possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
205
- }
238
+ // window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
239
+ // injected by the compiler/dev server for dynamic pages. It lets us resolve
240
+ // the tokenized `$` manifest directly instead of probing literal 404s.
241
+ const possiblePaths = buildManifestCandidatePaths(
242
+ window.location.pathname,
243
+ typeof window !== "undefined" ? window.__ROUTE__ : null,
244
+ );
245
+
246
+ if (possiblePaths.length === 0) return null;
206
247
 
207
248
  if (!skipNetwork) {
208
249
  // Fully-runtime dynamic import. Hidden behind `new Function` so any
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert';
2
+
3
+ // The module runs a top-level `await detectHyperspeed()` that touches the DOM,
4
+ // so stub the globals before importing it. querySelector returns truthy →
5
+ // skipNetwork → no import() probing during module init.
6
+ globalThis.window = { location: { pathname: '/' }, __ROUTE__: undefined };
7
+ globalThis.document = { querySelector: () => ({}) };
8
+
9
+ const { buildManifestCandidatePaths } = await import('./pre-compiled-manifest.js');
10
+
11
+ let passed = 0;
12
+ const test = (name, fn) => {
13
+ fn();
14
+ passed++;
15
+ console.log(` ok - ${name}`);
16
+ };
17
+
18
+ // Dynamic route: window.__ROUTE__ tells us the trailing segment is a param, so
19
+ // the very first candidate must be the tokenized `$` manifest — no literal
20
+ // `0.html.manifest.js` probes that are guaranteed to 404.
21
+ test('dynamic route resolves the $ manifest first (no literal probing)', () => {
22
+ const paths = buildManifestCandidatePaths('/brawlers/0', '/brawlers/:index');
23
+ assert.strictEqual(paths[0], '/vibe-hyperspeed/brawlers/$.html.manifest.js');
24
+ assert.ok(
25
+ !paths.includes('/vibe-hyperspeed/brawlers/0.html.manifest.js') ||
26
+ paths.indexOf('/vibe-hyperspeed/brawlers/$.html.manifest.js') <
27
+ paths.indexOf('/vibe-hyperspeed/brawlers/0.html.manifest.js'),
28
+ 'tokenized path must come before any literal path',
29
+ );
30
+ });
31
+
32
+ // Mid-path params tokenize by position, not just the filename.
33
+ test('tokenizes only the param segments named by the route', () => {
34
+ const paths = buildManifestCandidatePaths(
35
+ '/_internal/characters/troll',
36
+ '/_internal/characters/:key',
37
+ );
38
+ assert.strictEqual(
39
+ paths[0],
40
+ '/vibe-hyperspeed/_internal/characters/$.html.manifest.js',
41
+ );
42
+ });
43
+
44
+ // Static page: no route hint, behaviour unchanged, no duplicate candidates.
45
+ test('static top-level page resolves cleanly with no duplicates', () => {
46
+ const paths = buildManifestCandidatePaths('/armory', null);
47
+ assert.ok(paths.includes('/vibe-hyperspeed/armory.html.manifest.js'));
48
+ assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
49
+ });
50
+
51
+ // Subdirectory static page: strategies 2 and 3 collapse to the same URL — it
52
+ // must be probed once, not twice.
53
+ test('subdirectory page dedupes identical candidates', () => {
54
+ const paths = buildManifestCandidatePaths('/foo/bar', null);
55
+ assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
56
+ });
57
+
58
+ console.log(`\n${passed} passed`);