@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.
- package/CHANGELOG.md +26 -0
- package/README.md +48 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +198 -8
- package/compiler/src/compiler/component_tagger.rs +199 -50
- package/compiler/src/compiler/js_analyzer.rs +179 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +114 -4
- package/package.json +1 -1
- package/runtime/pre-compiled-manifest.js +101 -60
- package/runtime/pre-compiled-manifest.test.mjs +58 -0
|
@@ -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
|
|
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
|
-
|
|
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+(
|
|
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+(
|
|
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
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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`);
|