on master help sign in
@page takes lang and favicon

canonical and base are each one line of HTML in @head. favicon stays: it is inlined into the self-contained page.

commit65dec96be56cc1b7b64b99e1f8462294b1aa73a3 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 04:03 (2 weeks ago) parent2f8138a
10 files changed+39−73
DESIGN.md+3−2 crates/htmlang-core/src/ast.rs−2 crates/htmlang-core/src/codegen.rs+2−30 crates/htmlang-core/src/parser.rs+4−14 src/bin/htmlang_lsp/docs.rs+1−1 src/upgrade.rs+10−2 tests/regressions.rs+13 tests/snapshots.rs−20 tests/snapshots/new_directives.hl+4−1 tests/snapshots/new_directives.html+2−1
DESIGN.md+3−2
@@ -429,7 +429,7 @@ with `@slot name` (named blocks, with default content) and `@children`
429429## Page and head
430430
431431`@page TITLE` produces a full HTML document; without it the output is a fragment.
432−Its attributes set `lang`, `favicon`, `canonical` and `base`. `@meta NAME VALUE`
432+Its attributes set `lang` and `favicon` (inlined into the page). `@meta NAME VALUE`
433433adds a meta tag (`og:` names become Open Graph `property` tags), and
434434`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
435435link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
@@ -479,7 +479,8 @@ removed form with its replacement.
479479| `@with $x as y` | `@let y $x` |
480480| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
481481| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
482−| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
482+| `@lang`, `@favicon` | `@page [lang ..., favicon ...] Title` |
483+| `@canonical URL`, `@base URL` | `<link rel="canonical" href="URL">`, `<base href="URL">` in `@head` |
483484| `@og KEY VALUE` | `@meta og:KEY VALUE` |
484485| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
485486| `@defer` | Its content, directly |
crates/htmlang-core/src/ast.rs−2
@@ -13,8 +13,6 @@ pub struct Document {
1313 pub css_vars: Vec<(String, String)>,
1414 pub custom_css: Vec<String>,
1515 pub og_tags: Vec<(String, String)>,
16− pub canonical: Option<String>,
17− pub base_url: Option<String>,
1816 pub nodes: Vec<Node>,
1917}
2018
crates/htmlang-core/src/codegen.rs+2−30
@@ -697,30 +697,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
697697 None => String::new(),
698698 };
699699
700− // Canonical URL
701− let canonical_html = match &doc.canonical {
702− Some(url) => {
703− if dev {
704− format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
705− } else {
706− format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
707− }
708− }
709− None => String::new(),
710− };
711−
712− // Base URL
713− let base_html = match &doc.base_url {
714− Some(url) => {
715− if dev {
716− format!("<base href=\"{}\">\n", html_escape(url))
717− } else {
718− format!("<base href=\"{}\">", html_escape(url))
719− }
720− }
721− None => String::new(),
722− };
723−
724700 // Focus-visible CSS for interactive elements (accessibility)
725701 let focus_visible_css = if ctx.has_interactive {
726702 if dev {
@@ -745,7 +721,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
745721<meta charset=\"utf-8\">
746722<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
747723<title>{title}</title>
748−{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{head_html}\
724+{meta_html}{og_html}{favicon_html}{head_html}\
749725<style>
750726{reset_css}{element_css}\
751727</style>
@@ -757,8 +733,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
757733",
758734 title = html_escape(title),
759735 lang_attr = lang_attr,
760− base_html = base_html,
761− canonical_html = canonical_html,
762736 meta_html = meta_html,
763737 favicon_html = favicon_html,
764738 head_html = head_html,
@@ -769,11 +743,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
769743 )
770744 } else {
771745 format!(
772− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
746+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
773747 title = html_escape(title),
774748 lang_attr = lang_attr,
775− base_html = base_html,
776− canonical_html = canonical_html,
777749 meta_html = meta_html,
778750 og_html = og_html,
779751 favicon_html = favicon_html,
crates/htmlang-core/src/parser.rs+4−14
@@ -90,10 +90,6 @@ struct ParseContext {
9090 fn_lines: HashMap<String, usize>,
9191 /// Line numbers of attribute bundle definitions (name -> line)
9292 define_lines: HashMap<String, usize>,
93− /// Canonical URL
94− canonical: Option<String>,
95− /// Base URL for relative links
96− base_url: Option<String>,
9793}
9894
9995/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
@@ -170,8 +166,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
170166 let_lines: HashMap::new(),
171167 fn_lines: HashMap::new(),
172168 define_lines: HashMap::new(),
173− canonical: None,
174− base_url: None,
175169 };
176170 load_prelude(&mut ctx);
177171 let nodes = Evaluator.eval_block(&tree, &mut ctx);
@@ -189,8 +183,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
189183 css_vars: ctx.css_vars,
190184 custom_css: ctx.custom_css,
191185 og_tags: ctx.og_tags,
192− canonical: ctx.canonical,
193− base_url: ctx.base_url,
194186 nodes,
195187 },
196188 diagnostics: ctx.diagnostics,
@@ -292,7 +284,7 @@ impl Evaluator {
292284 });
293285 }
294286
295− // @page [lang en, favicon /f.png, canonical URL, base URL] Title
287+ // @page [lang en, favicon /f.png] Title
296288 if let Some(rest) = content.strip_prefix("@page ") {
297289 let rest = rest.trim_start();
298290 let title = if rest.starts_with('[') {
@@ -302,13 +294,11 @@ impl Evaluator {
302294 match attr.key.as_str() {
303295 "lang" => ctx.lang = Some(value),
304296 "favicon" => ctx.favicon = Some(value),
305− "canonical" => ctx.canonical = Some(value),
306− "base" => ctx.base_url = Some(value),
307297 other => ctx.diagnostics.push(Diagnostic {
308298 line: line_num,
309299 column: None,
310300 message: format!(
311− "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
301+ "unknown @page attribute '{}' (expected lang or favicon)",
312302 other
313303 ),
314304 severity: Severity::Warning,
@@ -1447,8 +1437,8 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
14471437 ("@css-property", "write an `@property` rule in an `@style` block"),
14481438 ("@lang", "use `@page [lang ...] Title`"),
14491439 ("@favicon", "use `@page [favicon ...] Title`"),
1450− ("@canonical", "use `@page [canonical ...] Title`"),
1451− ("@base", "use `@page [base ...] Title`"),
1440+ ("@canonical", "write `<link rel=\"canonical\" href=\"...\">` in `@head`"),
1441+ ("@base", "write `<base href=\"...\">` in `@head`"),
14521442 ("@og", "use `@meta og:NAME VALUE`"),
14531443 ("@debug", "removed: a layout needs no compile-time messages"),
14541444 ("@svg", "use `@image [inline] file.svg`"),
src/bin/htmlang_lsp/docs.rs+1−1
@@ -85,7 +85,7 @@ pub(crate) const ELEMENTS: &[Doc] = &[
8585pub(crate) const DIRECTIVES: &[Doc] = &[
8686 doc(
8787 "page",
88− "Produces a full HTML document with this title. Attributes set `lang`, `favicon`, `canonical` and `base`.",
88+ "Produces a full HTML document with this title. Attributes set `lang` and `favicon`.",
8989 "@page [lang en] My Site",
9090 ),
9191 doc(
src/upgrade.rs+10−2
@@ -828,10 +828,10 @@ fn rewrite_block(
828828 None
829829}
830830
831−/// Move `@lang`, `@favicon`, `@canonical` and `@base` into the `@page`
831+/// Move `@lang` and `@favicon` into the `@page`
832832/// line's attributes: `@page [lang en, favicon /f.png] Title`.
833833fn fold_head_directives(input: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
834− const FOLDED: &[&str] = &["lang", "favicon", "canonical", "base"];
834+ const FOLDED: &[&str] = &["lang", "favicon"];
835835 let lines: Vec<&str> = input.lines().collect();
836836 let mut attrs = Vec::new();
837837 let mut keep = Vec::with_capacity(lines.len());
@@ -908,6 +908,13 @@ fn rewrite_directive_line(
908908 return format!("{pad}@let {}", rest);
909909 }
910910 }
911+ // `@canonical URL` / `@base URL` → the HTML line in @head
912+ if let Some(url) = trimmed.strip_prefix("@canonical ") {
913+ return format!("{pad}@head\n{pad} <link rel=\"canonical\" href=\"{}\">", url.trim());
914+ }
915+ if let Some(url) = trimmed.strip_prefix("@base ") {
916+ return format!("{pad}@head\n{pad} <base href=\"{}\">", url.trim());
917+ }
911918 if let Some(rest) = trimmed.strip_prefix("@og ")
912919 && let Some((key, value)) = rest.trim().split_once(' ')
913920 {
@@ -1756,6 +1763,7 @@ mod tests {
17561763 "@page [lang en, favicon /f.png] Home\n@text hi\n"
17571764 );
17581765 assert_eq!(up("@og title \"My Page\""), "@meta og:title My Page");
1766+ assert_eq!(up("@canonical https://x.dev/a"), "@head\n <link rel=\"canonical\" href=\"https://x.dev/a\">");
17591767 assert_eq!(up("@debug hi $x\n@log $a $b\n@assert $x == 1\n@warn hi\n@text x"), "@text x");
17601768 assert_eq!(up("@component card $t\n @text $t"), "@let card $t\n @text $t");
17611769 assert_eq!(up("@el [color $on ? green : gray]"), "@el [color if($on, green, gray)]");
tests/regressions.rs+13
@@ -463,3 +463,16 @@ fn css_values_pass_through() {
463463 assert!(out.contains("content:\"→ \""), "{}", out);
464464 assert!(out.contains("content:attr(title)"), "{}", out);
465465}
466+
467+#[test]
468+fn page_takes_lang_and_favicon_only() {
469+ let result = parser::parse("@page [lang en, canonical https://x.dev] T\n");
470+ assert!(
471+ result
472+ .diagnostics
473+ .iter()
474+ .any(|d| d.message.contains("unknown @page attribute 'canonical'")),
475+ "{:?}",
476+ result.diagnostics
477+ );
478+}
tests/snapshots.rs−20
@@ -3236,26 +3236,6 @@ fn search_element() {
32363236 assert!(output.contains("<search>"), "search: {}", output);
32373237}
32383238
3239−#[test]
3240−fn canonical_directive() {
3241− let output = compile("@page [canonical https://example.com/page] T\n@text Hello");
3242− assert!(
3243− output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"),
3244− "canonical: {}",
3245− output
3246− );
3247−}
3248−
3249−#[test]
3250−fn base_directive() {
3251− let output = compile("@page [base https://example.com/] T\n@text Hello");
3252− assert!(
3253− output.contains("<base href=\"https://example.com/\">"),
3254− "base: {}",
3255− output
3256− );
3257−}
3258−
32593239#[test]
32603240fn font_face_directive() {
32613241 let output = compile("@page T\n@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>\n@text Hello");
tests/snapshots/new_directives.hl+4−1
@@ -1,4 +1,7 @@
1−@page [canonical https://example.com/page, base https://example.com/] My Site
1+@page My Site
2+@head
3+ <base href="https://example.com/">
4+ <link rel="canonical" href="https://example.com/page">
25@style
36 @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }
47@head
tests/snapshots/new_directives.html+2−1
@@ -1,4 +1,5 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><base href="https://example.com/"><link rel="canonical" href="https://example.com/page"><link rel="preload" href="fonts/inter.woff2" as="font" crossorigin><link rel="preload" href="fonts/mono.ttf" as="font" crossorigin><script type="application/ld+json">
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>My Site</title><base href="https://example.com/">
2+<link rel="canonical" href="https://example.com/page"><link rel="preload" href="fonts/inter.woff2" as="font" crossorigin><link rel="preload" href="fonts/mono.ttf" as="font" crossorigin><script type="application/ld+json">
23 {
34 "@context": "https://schema.org",
45 "@type": "WebPage",