1use kcode_k1_web_package::{PackageError, SourceFile, SourcePackage, WebId};
2use serde::{Deserialize, Serialize};
3use std::{
4 fmt::{Display, Formatter},
5 ops::Range,
6};
7
8const HTML_ENTRY: &str = "const frame=document.createElement(\"iframe\");\nframe.hidden=true;\nframe.src=new URL(\"./Code.html\",import.meta.url).href;\ndocument.body.append(frame);\nawait new Promise((resolve,reject)=>{frame.addEventListener(\"load\",resolve,{once:true});frame.addEventListener(\"error\",()=>reject(new Error(\"Code.html failed to load\")),{once:true});});\nexport const codeWindow=frame.contentWindow;\n";
9const HTML_TESTS: &str = "import {codeWindow} from \"./k1-entry.js\";\nexport async function runTests(){const test=codeWindow.globalThis.runTests;if(typeof test!==\"function\")throw new Error(\"Code.html must define globalThis.runTests\");return await test.call(codeWindow);}\n";
10const CSS_ENTRY: &str = "const link=document.createElement(\"link\");\nlink.rel=\"stylesheet\";\nlink.href=new URL(\"./Code.css\",import.meta.url).href;\ndocument.head.append(link);\nawait new Promise((resolve,reject)=>{link.addEventListener(\"load\",resolve,{once:true});link.addEventListener(\"error\",()=>reject(new Error(\"Code.css failed to load\")),{once:true});});\nexport const sheet=link.sheet;\nvoid sheet.cssRules;\n";
11const CSS_TESTS: &str = "import {sheet} from \"./k1-entry.js\";\nexport async function runTests(){void sheet.cssRules;}\n";
12
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub enum Language {
15 JavaScript,
16 Html,
17 Css,
18}
19impl Language {
20 pub const fn code_path(self) -> &'static str {
21 match self {
22 Self::JavaScript => "Code.js",
23 Self::Html => "Code.html",
24 Self::Css => "Code.css",
25 }
26 }
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
30#[serde(deny_unknown_fields)]
31pub struct Dependency {
32 authority: String,
33 name: String,
34 selector: String,
35}
36impl Dependency {
37 pub fn authority(&self) -> &str {
38 &self.authority
39 }
40 pub fn name(&self) -> &str {
41 &self.name
42 }
43 pub fn selector(&self) -> &str {
44 &self.selector
45 }
46}
47#[derive(Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49struct Header {
50 dependencies: Vec<Dependency>,
51}
52#[derive(Serialize)]
53struct Manifest<'a> {
54 name: &'a str,
55 version: String,
56 entry: &'a str,
57 tests: &'a str,
58 dependencies: Vec<&'a Dependency>,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct DocumentError(String);
63impl DocumentError {
64 pub fn message(&self) -> &str {
65 &self.0
66 }
67}
68impl Display for DocumentError {
69 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
70 f.write_str(&self.0)
71 }
72}
73impl std::error::Error for DocumentError {}
74impl From<PackageError> for DocumentError {
75 fn from(value: PackageError) -> Self {
76 Self(value.to_string())
77 }
78}
79fn fail<T>(message: impl Into<String>) -> Result<T, DocumentError> {
80 Err(DocumentError(message.into()))
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct CodeDocument {
85 id: WebId,
86 documentation: Vec<u8>,
87 language: Language,
88 code: Vec<u8>,
89 dependencies: Vec<Dependency>,
90}
91impl CodeDocument {
92 pub fn new(
93 id: WebId,
94 documentation: Vec<u8>,
95 language: Language,
96 code: Vec<u8>,
97 ) -> Result<Self, DocumentError> {
98 let dependencies = parse_header(&documentation)?;
99 std::str::from_utf8(&code).map_err(|_| DocumentError("code must be UTF-8".into()))?;
100 let value = Self {
101 id,
102 documentation,
103 language,
104 code,
105 dependencies,
106 };
107 value.to_source_package()?;
108 Ok(value)
109 }
110 pub fn id(&self) -> &WebId {
111 &self.id
112 }
113 pub fn documentation(&self) -> &[u8] {
114 &self.documentation
115 }
116 pub const fn language(&self) -> Language {
117 self.language
118 }
119 pub fn code(&self) -> &[u8] {
120 &self.code
121 }
122 pub fn dependencies(&self) -> &[Dependency] {
123 &self.dependencies
124 }
125 pub fn to_source_package(&self) -> Result<SourcePackage, DocumentError> {
126 let (entry, tests) = if self.language == Language::JavaScript {
127 ("Code.js", "Code.js")
128 } else {
129 ("k1-entry.js", "k1-tests.js")
130 };
131 let mut dependencies = self.dependencies.iter().collect::<Vec<_>>();
132 dependencies.sort_by(|a, b| {
133 (&a.authority, &a.name, &a.selector).cmp(&(&b.authority, &b.name, &b.selector))
134 });
135 let manifest = Manifest {
136 name: self.id.family().logical_name(),
137 version: self.id.version().to_string(),
138 entry,
139 tests,
140 dependencies,
141 };
142 let mut files = vec![
143 SourceFile::new("Documentation.md", self.documentation.clone()),
144 SourceFile::new(self.language.code_path(), self.code.clone()),
145 SourceFile::new(
146 "k1-web.json",
147 serde_json::to_vec(&manifest).map_err(|e| DocumentError(e.to_string()))?,
148 ),
149 ];
150 match self.language {
151 Language::JavaScript => {}
152 Language::Html => {
153 files.push(SourceFile::new(
154 "k1-entry.js",
155 HTML_ENTRY.as_bytes().to_vec(),
156 ));
157 files.push(SourceFile::new(
158 "k1-tests.js",
159 HTML_TESTS.as_bytes().to_vec(),
160 ));
161 }
162 Language::Css => {
163 files.push(SourceFile::new(
164 "k1-entry.js",
165 CSS_ENTRY.as_bytes().to_vec(),
166 ));
167 files.push(SourceFile::new(
168 "k1-tests.js",
169 CSS_TESTS.as_bytes().to_vec(),
170 ));
171 }
172 }
173 SourcePackage::new(self.id.clone(), files).map_err(Into::into)
174 }
175 pub fn from_source_package(source: &SourcePackage) -> Result<Self, DocumentError> {
176 let documentation = source
177 .files()
178 .iter()
179 .find(|f| f.path() == "Documentation.md")
180 .ok_or_else(|| DocumentError("noncanonical code-document projection".into()))?
181 .bytes()
182 .to_vec();
183 for language in [Language::JavaScript, Language::Html, Language::Css] {
184 let Some(file) = source
185 .files()
186 .iter()
187 .find(|f| f.path() == language.code_path())
188 else {
189 continue;
190 };
191 let Ok(value) = Self::new(
192 source.id().clone(),
193 documentation.clone(),
194 language,
195 file.bytes().to_vec(),
196 ) else {
197 continue;
198 };
199 if value
200 .to_source_package()
201 .is_ok_and(|made| made.files() == source.files())
202 {
203 return Ok(value);
204 }
205 }
206 fail("noncanonical code-document projection")
207 }
208 pub fn chunk_ranges(&self) -> Vec<Range<usize>> {
209 let text = std::str::from_utf8(&self.code).expect("CodeDocument maintains UTF-8");
210 let points = match self.language {
211 Language::JavaScript => js_points(text),
212 Language::Css => css_points(text),
213 Language::Html => html_points(text),
214 };
215 points.map_or_else(|| whole(self.code.len()), |p| pack(text, p))
216 }
217}
218
219fn parse_header(bytes: &[u8]) -> Result<Vec<Dependency>, DocumentError> {
220 let text = std::str::from_utf8(bytes)
221 .map_err(|_| DocumentError("Documentation.md must be UTF-8".into()))?;
222 let rest = text
223 .strip_prefix("<!-- k1-web/v1\n")
224 .ok_or_else(|| DocumentError("missing exact documentation header".into()))?;
225 let (json, rest) = rest
226 .split_once('\n')
227 .ok_or_else(|| DocumentError("incomplete documentation header".into()))?;
228 if !(rest == "-->" || rest.starts_with("-->\n")) {
229 return fail("documentation header must end with an exact --> line");
230 }
231 let header: Header = serde_json::from_str(json)
232 .map_err(|e| DocumentError(format!("invalid documentation header: {e}")))?;
233 if serde_json::to_string(&header).map_err(|e| DocumentError(e.to_string()))? != json {
234 return fail("documentation header JSON must be canonical and compact");
235 }
236 if header
237 .dependencies
238 .iter()
239 .any(|d| d.authority.is_empty() || d.name.is_empty() || d.selector.is_empty())
240 {
241 return fail("dependency fields must be nonempty");
242 }
243 let mut unique = header.dependencies.clone();
244 unique.sort();
245 unique.dedup();
246 if unique.len() != header.dependencies.len() {
247 return fail("duplicate dependency declaration");
248 }
249 Ok(header.dependencies)
250}
251
252fn whole(len: usize) -> Vec<Range<usize>> {
253 std::iter::once(0..len).collect()
254}
255fn pack(text: &str, mut points: Vec<usize>) -> Vec<Range<usize>> {
256 if text.is_empty() {
257 return whole(0);
258 }
259 if text.chars().count() < 800 {
260 return whole(text.len());
261 }
262 points.push(0);
263 points.push(text.len());
264 points.sort_unstable();
265 points.dedup();
266 let mut out = Vec::new();
267 let mut start = 0;
268 while start < text.len() {
269 let end = points
270 .iter()
271 .copied()
272 .filter(|&p| p > start)
273 .find(|&p| {
274 text[start..p].chars().count() >= 800
275 && (p == text.len() || text[p..].chars().count() >= 800)
276 })
277 .unwrap_or(text.len());
278 out.push(start..end);
279 start = end;
280 }
281 out
282}
283
284#[derive(Clone, Copy)]
285enum Brace {
286 Class(bool),
287 Function,
288 Member,
289 Other,
290}
291fn js_points(text: &str) -> Option<Vec<usize>> {
292 let b = text.as_bytes();
293 let mut out = vec![0];
294 let (mut i, mut paren, mut bracket) = (0, 0usize, 0usize);
295 let mut braces = Vec::new();
296 let mut pending = None;
297 let mut regex = true;
298 let mut statement = true;
299 let mut last = 0;
300 while i < b.len() {
301 match b[i] {
302 b'\'' | b'"' => {
303 i = skip_quote(b, i)?;
304 regex = false;
305 statement = false;
306 }
307 b'`' => {
308 i = skip_template(b, i)?;
309 regex = false;
310 statement = false;
311 }
312 b'/' if b.get(i + 1) == Some(&b'/') => {
313 i = b[i + 2..]
314 .iter()
315 .position(|&c| c == b'\n')
316 .map_or(b.len(), |n| i + n + 3);
317 }
318 b'/' if b.get(i + 1) == Some(&b'*') => {
319 i = find_bytes(b, i + 2, b"*/")? + 2;
320 }
321 b'/' if regex => {
322 i = skip_regex(b, i)?;
323 regex = false;
324 statement = false;
325 }
326 c if ident_start(c) => {
327 let start = i;
328 i += 1;
329 while i < b.len() && ident_continue(b[i]) {
330 i += 1;
331 }
332 let word = &text[start..i];
333 if word == "class" {
334 pending = Some(Brace::Class(statement));
335 }
336 if word == "function" && statement {
337 pending = Some(Brace::Function);
338 }
339 regex = matches!(
340 word,
341 "return"
342 | "throw"
343 | "case"
344 | "delete"
345 | "void"
346 | "typeof"
347 | "new"
348 | "yield"
349 | "await"
350 | "else"
351 | "do"
352 | "in"
353 | "of"
354 | "instanceof"
355 );
356 if !matches!(word, "export" | "default" | "async") {
357 statement = false;
358 }
359 last = b'x';
360 }
361 b'(' => {
362 paren += 1;
363 i += 1;
364 regex = true;
365 last = b'(';
366 }
367 b')' => {
368 paren = paren.checked_sub(1)?;
369 i += 1;
370 regex = false;
371 last = b')';
372 }
373 b'[' => {
374 bracket += 1;
375 i += 1;
376 regex = true;
377 last = b'[';
378 }
379 b']' => {
380 bracket = bracket.checked_sub(1)?;
381 i += 1;
382 regex = false;
383 last = b']';
384 }
385 b'{' => {
386 let kind = if paren == 0 && bracket == 0 {
387 pending.take().unwrap_or_else(|| {
388 if matches!(braces.last(), Some(Brace::Class(_))) && last == b')' {
389 Brace::Member
390 } else {
391 Brace::Other
392 }
393 })
394 } else {
395 Brace::Other
396 };
397 braces.push(kind);
398 i += 1;
399 regex = true;
400 last = b'{';
401 }
402 b'}' => {
403 if paren != 0 || bracket != 0 {
404 return None;
405 }
406 let kind = braces.pop()?;
407 i += 1;
408 regex = false;
409 last = b'}';
410 let boundary = matches!(kind, Brace::Function) && braces.is_empty()
411 || matches!(kind, Brace::Class(true)) && braces.is_empty()
412 || matches!(kind, Brace::Member)
413 && matches!(braces.last(), Some(Brace::Class(_)));
414 if boundary {
415 out.push(i);
416 statement = braces.is_empty();
417 }
418 }
419 b';' => {
420 i += 1;
421 regex = true;
422 pending = None;
423 if paren == 0
424 && bracket == 0
425 && (braces.is_empty() || matches!(braces.last(), Some(Brace::Class(_))))
426 {
427 out.push(i);
428 statement = braces.is_empty();
429 }
430 last = b';';
431 }
432 c if c.is_ascii_whitespace() => i += 1,
433 c if c.is_ascii_digit() => {
434 i += 1;
435 while i < b.len() && (ident_continue(b[i]) || b"._".contains(&b[i])) {
436 i += 1;
437 }
438 regex = false;
439 statement = false;
440 last = b'n';
441 }
442 b'/' => {
443 i += 1;
444 regex = true;
445 statement = false;
446 last = b'/';
447 }
448 c => {
449 i += 1;
450 regex = !matches!(c, b'.');
451 if !matches!(c, b',' | b':') {
452 statement = false;
453 }
454 last = c;
455 }
456 }
457 }
458 if paren != 0 || bracket != 0 || !braces.is_empty() {
459 None
460 } else {
461 out.push(b.len());
462 Some(out)
463 }
464}
465fn ident_start(c: u8) -> bool {
466 c.is_ascii_alphabetic() || matches!(c, b'_' | b'$')
467}
468fn ident_continue(c: u8) -> bool {
469 ident_start(c) || c.is_ascii_digit()
470}
471fn skip_quote(b: &[u8], mut i: usize) -> Option<usize> {
472 let quote = b[i];
473 i += 1;
474 while i < b.len() {
475 match b[i] {
476 b'\\' => i = (i + 2).min(b.len()),
477 c if c == quote => return Some(i + 1),
478 b'\n' | b'\r' => return None,
479 _ => i += 1,
480 }
481 }
482 None
483}
484fn skip_regex(b: &[u8], mut i: usize) -> Option<usize> {
485 i += 1;
486 let mut class = false;
487 while i < b.len() {
488 match b[i] {
489 b'\\' => i = (i + 2).min(b.len()),
490 b'[' => {
491 class = true;
492 i += 1;
493 }
494 b']' => {
495 class = false;
496 i += 1;
497 }
498 b'/' if !class => {
499 i += 1;
500 while i < b.len() && b[i].is_ascii_alphabetic() {
501 i += 1;
502 }
503 return Some(i);
504 }
505 b'\n' | b'\r' => return None,
506 _ => i += 1,
507 }
508 }
509 None
510}
511fn skip_template(b: &[u8], mut i: usize) -> Option<usize> {
512 i += 1;
513 while i < b.len() {
514 match b[i] {
515 b'\\' => i = (i + 2).min(b.len()),
516 b'`' => return Some(i + 1),
517 b'$' if b.get(i + 1) == Some(&b'{') => i = skip_expression(b, i + 2)?,
518 _ => i += 1,
519 }
520 }
521 None
522}
523fn skip_expression(b: &[u8], mut i: usize) -> Option<usize> {
524 let (mut depth, mut paren, mut bracket) = (1usize, 0usize, 0usize);
525 let mut regex = true;
526 while i < b.len() {
527 match b[i] {
528 b'\'' | b'"' => {
529 i = skip_quote(b, i)?;
530 regex = false;
531 }
532 b'`' => {
533 i = skip_template(b, i)?;
534 regex = false;
535 }
536 b'/' if b.get(i + 1) == Some(&b'/') => {
537 i = b[i + 2..]
538 .iter()
539 .position(|&c| c == b'\n')
540 .map_or(b.len(), |n| i + n + 3)
541 }
542 b'/' if b.get(i + 1) == Some(&b'*') => i = find_bytes(b, i + 2, b"*/")? + 2,
543 b'/' if regex => {
544 i = skip_regex(b, i)?;
545 regex = false;
546 }
547 b'{' => {
548 depth += 1;
549 i += 1;
550 regex = true;
551 }
552 b'}' => {
553 depth -= 1;
554 i += 1;
555 if depth == 0 {
556 return (paren == 0 && bracket == 0).then_some(i);
557 }
558 regex = false;
559 }
560 b'(' => {
561 paren += 1;
562 i += 1;
563 regex = true;
564 }
565 b')' => {
566 paren = paren.checked_sub(1)?;
567 i += 1;
568 regex = false;
569 }
570 b'[' => {
571 bracket += 1;
572 i += 1;
573 regex = true;
574 }
575 b']' => {
576 bracket = bracket.checked_sub(1)?;
577 i += 1;
578 regex = false;
579 }
580 c if c.is_ascii_whitespace() => i += 1,
581 c if ident_continue(c) => {
582 i += 1;
583 regex = false;
584 }
585 _ => {
586 i += 1;
587 regex = true;
588 }
589 }
590 }
591 None
592}
593fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
594 haystack[start..]
595 .windows(needle.len())
596 .position(|w| w == needle)
597 .map(|n| start + n)
598}
599
600fn css_points(text: &str) -> Option<Vec<usize>> {
601 let b = text.as_bytes();
602 let mut out = vec![0];
603 let (mut i, mut braces, mut paren, mut bracket) = (0, 0usize, 0usize, 0usize);
604 while i < b.len() {
605 match b[i] {
606 b'\'' | b'"' => i = skip_quote(b, i)?,
607 b'/' if b.get(i + 1) == Some(&b'*') => i = find_bytes(b, i + 2, b"*/")? + 2,
608 b'{' => {
609 braces += 1;
610 i += 1;
611 }
612 b'}' => {
613 braces = braces.checked_sub(1)?;
614 i += 1;
615 out.push(i);
616 }
617 b'(' => {
618 paren += 1;
619 i += 1;
620 }
621 b')' => {
622 paren = paren.checked_sub(1)?;
623 i += 1;
624 }
625 b'[' => {
626 bracket += 1;
627 i += 1;
628 }
629 b']' => {
630 bracket = bracket.checked_sub(1)?;
631 i += 1;
632 }
633 b';' if braces == 0 && paren == 0 && bracket == 0 => {
634 i += 1;
635 out.push(i);
636 }
637 _ => i += 1,
638 }
639 }
640 if braces + paren + bracket == 0 {
641 out.push(b.len());
642 Some(out)
643 } else {
644 None
645 }
646}
647
648fn html_points(text: &str) -> Option<Vec<usize>> {
649 let b = text.as_bytes();
650 let mut out = vec![0];
651 let mut stack: Vec<String> = Vec::new();
652 let mut i = 0;
653 while i < b.len() {
654 if b[i] != b'<' {
655 i = b[i..]
656 .iter()
657 .position(|&c| c == b'<')
658 .map_or(b.len(), |n| i + n);
659 out.push(i);
660 continue;
661 }
662 if starts_ci(b, i, b"<!--") {
663 i = find_bytes(b, i + 4, b"-->")? + 3;
664 out.push(i);
665 continue;
666 }
667 if starts_ci(b, i, b"</") {
668 let (name, end) = close_tag(b, i)?;
669 if stack.pop().as_deref() != Some(&name) {
670 return None;
671 }
672 i = end;
673 out.push(i);
674 continue;
675 }
676 if starts_ci(b, i, b"<!") || starts_ci(b, i, b"<?") {
677 i = tag_end(b, i + 2)?;
678 out.push(i);
679 continue;
680 }
681 let (name, end, closed, script_type) = start_tag(b, i)?;
682 if !closed && (name == "script" || name == "style") {
683 let (raw_end, close_end) = raw_close(b, end, &name)?;
684 let raw = std::str::from_utf8(&b[end..raw_end]).ok()?;
685 let points = if name == "style" {
686 css_points(raw)?
687 } else if script_type.as_deref().is_none_or(is_javascript_type) {
688 js_points(raw)?
689 } else {
690 vec![0, raw.len()]
691 };
692 out.extend(
693 points
694 .into_iter()
695 .filter(|&p| p > 0 && p < raw.len())
696 .map(|p| end + p),
697 );
698 i = close_end;
699 out.push(i);
700 continue;
701 }
702 i = end;
703 if closed || is_void(&name) {
704 out.push(i);
705 } else {
706 stack.push(name);
707 }
708 }
709 if stack.is_empty() {
710 out.push(b.len());
711 Some(out)
712 } else {
713 None
714 }
715}
716fn starts_ci(b: &[u8], at: usize, needle: &[u8]) -> bool {
717 b.get(at..at + needle.len())
718 .is_some_and(|v| v.eq_ignore_ascii_case(needle))
719}
720fn tag_end(b: &[u8], mut i: usize) -> Option<usize> {
721 let mut quote = None;
722 while i < b.len() {
723 let c = b[i];
724 if let Some(q) = quote {
725 if c == q {
726 quote = None;
727 }
728 } else if matches!(c, b'\'' | b'"') {
729 quote = Some(c);
730 } else if c == b'>' {
731 return Some(i + 1);
732 }
733 i += 1;
734 }
735 None
736}
737fn start_tag(b: &[u8], at: usize) -> Option<(String, usize, bool, Option<String>)> {
738 let mut i = at + 1;
739 let start = i;
740 while i < b.len() && (ident_continue(b[i]) || matches!(b[i], b':' | b'-')) {
741 i += 1;
742 }
743 if i == start {
744 return None;
745 }
746 let name = std::str::from_utf8(&b[start..i]).ok()?.to_ascii_lowercase();
747 let mut kind = None;
748 loop {
749 while b.get(i).is_some_and(u8::is_ascii_whitespace) {
750 i += 1;
751 }
752 if b.get(i) == Some(&b'>') {
753 return Some((name, i + 1, false, kind));
754 }
755 if b.get(i) == Some(&b'/') && b.get(i + 1) == Some(&b'>') {
756 return Some((name, i + 2, true, kind));
757 }
758 let a = i;
759 while i < b.len() && (ident_continue(b[i]) || matches!(b[i], b':' | b'-')) {
760 i += 1;
761 }
762 if i == a {
763 return None;
764 }
765 let attr = std::str::from_utf8(&b[a..i]).ok()?.to_ascii_lowercase();
766 while b.get(i).is_some_and(u8::is_ascii_whitespace) {
767 i += 1;
768 }
769 let mut value = "";
770 if b.get(i) == Some(&b'=') {
771 i += 1;
772 while b.get(i).is_some_and(u8::is_ascii_whitespace) {
773 i += 1;
774 }
775 let q = *b.get(i)?;
776 let v;
777 if matches!(q, b'\'' | b'"') {
778 i += 1;
779 v = i;
780 while b.get(i).is_some_and(|&c| c != q) {
781 i += 1;
782 }
783 value = std::str::from_utf8(&b[v..i]).ok()?;
784 i += 1;
785 } else {
786 v = i;
787 while b
788 .get(i)
789 .is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'>' | b'/'))
790 {
791 i += 1;
792 }
793 value = std::str::from_utf8(&b[v..i]).ok()?;
794 }
795 }
796 if attr == "type" {
797 kind = Some(value.trim().to_ascii_lowercase());
798 }
799 }
800}
801fn close_tag(b: &[u8], at: usize) -> Option<(String, usize)> {
802 let mut i = at + 2;
803 let start = i;
804 while i < b.len() && (ident_continue(b[i]) || matches!(b[i], b':' | b'-')) {
805 i += 1;
806 }
807 let name = std::str::from_utf8(&b[start..i]).ok()?.to_ascii_lowercase();
808 while b.get(i).is_some_and(u8::is_ascii_whitespace) {
809 i += 1;
810 }
811 (b.get(i) == Some(&b'>') && !name.is_empty()).then_some((name, i + 1))
812}
813fn raw_close(b: &[u8], mut i: usize, name: &str) -> Option<(usize, usize)> {
814 while i < b.len() {
815 let n = b[i..].iter().position(|&c| c == b'<')? + i;
816 if starts_ci(b, n, b"</")
817 && let Some((found, end)) = close_tag(b, n)
818 && found == name
819 {
820 return Some((n, end));
821 }
822 i = n + 1;
823 }
824 None
825}
826fn is_javascript_type(value: &str) -> bool {
827 matches!(
828 value,
829 "" | "module"
830 | "text/javascript"
831 | "application/javascript"
832 | "text/ecmascript"
833 | "application/ecmascript"
834 )
835}
836fn is_void(name: &str) -> bool {
837 matches!(
838 name,
839 "area"
840 | "base"
841 | "br"
842 | "col"
843 | "embed"
844 | "hr"
845 | "img"
846 | "input"
847 | "link"
848 | "meta"
849 | "param"
850 | "source"
851 | "track"
852 | "wbr"
853 )
854}