Skip to main content

kcode_k1_web_code_document/
lib.rs

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}