Skip to main content

kcode_k1_web_code_document/
lib.rs

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}