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