on master help sign in
@page takes lang and favicon
65dec96iko
DESIGN.md+3−2
@@ -429,7 +429,7 @@ with `@slot name` (named blocks, with default content) and `@children`
429## Page and head
429## Page and head
430
430
431`@page TITLE` produces a full HTML document; without it the output is a fragment.
431`@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`
433adds a meta tag (`og:` names become Open Graph `property` tags), and
433adds a meta tag (`og:` names become Open Graph `property` tags), and
434`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
434`@head` holds any other raw HTML for the `<head>` (fonts, JSON-LD, a manifest
435link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
435link). Translations are a JSON file per locale: `@data $t locales/$lang.json`.
@@ -479,7 +479,8 @@ removed form with its replacement.
479| `@with $x as y` | `@let y $x` |
479| `@with $x as y` | `@let y $x` |
480| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
480| `@extends layout.hl`, `@layout file` | `@include layout.hl` and a call to the layout, now a function (both files are converted) |
481| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
481| `@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` |
483| `@og KEY VALUE` | `@meta og:KEY VALUE` |
484| `@og KEY VALUE` | `@meta og:KEY VALUE` |
484| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
485| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
485| `@defer` | Its content, directly |
486| `@defer` | Its content, directly |
crates/htmlang-core/src/ast.rs−2
@@ -13,8 +13,6 @@ pub struct Document {
13 pub css_vars: Vec<(String, String)>,
13 pub css_vars: Vec<(String, String)>,
14 pub custom_css: Vec<String>,
14 pub custom_css: Vec<String>,
15 pub og_tags: Vec<(String, String)>,
15 pub og_tags: Vec<(String, String)>,
16− pub canonical: Option<String>,
17− pub base_url: Option<String>,
18 pub nodes: Vec<Node>,
16 pub nodes: Vec<Node>,
19}
17}
20
18
crates/htmlang-core/src/codegen.rs+2−30
@@ -697,30 +697,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
697 None => String::new(),
697 None => String::new(),
698 };
698 };
699
699
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−
724 // Focus-visible CSS for interactive elements (accessibility)
700 // Focus-visible CSS for interactive elements (accessibility)
725 let focus_visible_css = if ctx.has_interactive {
701 let focus_visible_css = if ctx.has_interactive {
726 if dev {
702 if dev {
@@ -745,7 +721,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
745<meta charset=\"utf-8\">
721<meta charset=\"utf-8\">
746<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
722<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
747<title>{title}</title>
723<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}\
749<style>
725<style>
750{reset_css}{element_css}\
726{reset_css}{element_css}\
751</style>
727</style>
@@ -757,8 +733,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
757",
733",
758 title = html_escape(title),
734 title = html_escape(title),
759 lang_attr = lang_attr,
735 lang_attr = lang_attr,
760− base_html = base_html,
761− canonical_html = canonical_html,
762 meta_html = meta_html,
736 meta_html = meta_html,
763 favicon_html = favicon_html,
737 favicon_html = favicon_html,
764 head_html = head_html,
738 head_html = head_html,
@@ -769,11 +743,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
769 )
743 )
770 } else {
744 } else {
771 format!(
745 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>",
773 title = html_escape(title),
747 title = html_escape(title),
774 lang_attr = lang_attr,
748 lang_attr = lang_attr,
775− base_html = base_html,
776− canonical_html = canonical_html,
777 meta_html = meta_html,
749 meta_html = meta_html,
778 og_html = og_html,
750 og_html = og_html,
779 favicon_html = favicon_html,
751 favicon_html = favicon_html,
crates/htmlang-core/src/parser.rs+4−14
@@ -90,10 +90,6 @@ struct ParseContext {
90 fn_lines: HashMap<String, usize>,
90 fn_lines: HashMap<String, usize>,
91 /// Line numbers of attribute bundle definitions (name -> line)
91 /// Line numbers of attribute bundle definitions (name -> line)
92 define_lines: HashMap<String, usize>,
92 define_lines: HashMap<String, usize>,
93− /// Canonical URL
94− canonical: Option<String>,
95− /// Base URL for relative links
96− base_url: Option<String>,
97}
93}
98
94
99/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
95/// 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 {
170 let_lines: HashMap::new(),
166 let_lines: HashMap::new(),
171 fn_lines: HashMap::new(),
167 fn_lines: HashMap::new(),
172 define_lines: HashMap::new(),
168 define_lines: HashMap::new(),
173− canonical: None,
174− base_url: None,
175 };
169 };
176 load_prelude(&mut ctx);
170 load_prelude(&mut ctx);
177 let nodes = Evaluator.eval_block(&tree, &mut ctx);
171 let nodes = Evaluator.eval_block(&tree, &mut ctx);
@@ -189,8 +183,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
189 css_vars: ctx.css_vars,
183 css_vars: ctx.css_vars,
190 custom_css: ctx.custom_css,
184 custom_css: ctx.custom_css,
191 og_tags: ctx.og_tags,
185 og_tags: ctx.og_tags,
192− canonical: ctx.canonical,
193− base_url: ctx.base_url,
194 nodes,
186 nodes,
195 },
187 },
196 diagnostics: ctx.diagnostics,
188 diagnostics: ctx.diagnostics,
@@ -292,7 +284,7 @@ impl Evaluator {
292 });
284 });
293 }
285 }
294
286
295− // @page [lang en, favicon /f.png, canonical URL, base URL] Title
287+ // @page [lang en, favicon /f.png] Title
296 if let Some(rest) = content.strip_prefix("@page ") {
288 if let Some(rest) = content.strip_prefix("@page ") {
297 let rest = rest.trim_start();
289 let rest = rest.trim_start();
298 let title = if rest.starts_with('[') {
290 let title = if rest.starts_with('[') {
@@ -302,13 +294,11 @@ impl Evaluator {
302 match attr.key.as_str() {
294 match attr.key.as_str() {
303 "lang" => ctx.lang = Some(value),
295 "lang" => ctx.lang = Some(value),
304 "favicon" => ctx.favicon = Some(value),
296 "favicon" => ctx.favicon = Some(value),
305− "canonical" => ctx.canonical = Some(value),
306− "base" => ctx.base_url = Some(value),
307 other => ctx.diagnostics.push(Diagnostic {
297 other => ctx.diagnostics.push(Diagnostic {
308 line: line_num,
298 line: line_num,
309 column: None,
299 column: None,
310 message: format!(
300 message: format!(
311− "unknown @page attribute '{}' (expected lang, favicon, canonical or base)",
301+ "unknown @page attribute '{}' (expected lang or favicon)",
312 other
302 other
313 ),
303 ),
314 severity: Severity::Warning,
304 severity: Severity::Warning,
@@ -1447,8 +1437,8 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
1447 ("@css-property", "write an `@property` rule in an `@style` block"),
1437 ("@css-property", "write an `@property` rule in an `@style` block"),
1448 ("@lang", "use `@page [lang ...] Title`"),
1438 ("@lang", "use `@page [lang ...] Title`"),
1449 ("@favicon", "use `@page [favicon ...] Title`"),
1439 ("@favicon", "use `@page [favicon ...] Title`"),
1450− ("@canonical", "use `@page [canonical ...] Title`"),
1440+ ("@canonical", "write `<link rel=\"canonical\" href=\"...\">` in `@head`"),
1451− ("@base", "use `@page [base ...] Title`"),
1441+ ("@base", "write `<base href=\"...\">` in `@head`"),
1452 ("@og", "use `@meta og:NAME VALUE`"),
1442 ("@og", "use `@meta og:NAME VALUE`"),
1453 ("@debug", "removed: a layout needs no compile-time messages"),
1443 ("@debug", "removed: a layout needs no compile-time messages"),
1454 ("@svg", "use `@image [inline] file.svg`"),
1444 ("@svg", "use `@image [inline] file.svg`"),
src/bin/htmlang_lsp/docs.rs+1−1
@@ -85,7 +85,7 @@ pub(crate) const ELEMENTS: &[Doc] = &[
85pub(crate) const DIRECTIVES: &[Doc] = &[
85pub(crate) const DIRECTIVES: &[Doc] = &[
86 doc(
86 doc(
87 "page",
87 "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`.",
89 "@page [lang en] My Site",
89 "@page [lang en] My Site",
90 ),
90 ),
91 doc(
91 doc(
src/upgrade.rs+10−2
@@ -828,10 +828,10 @@ fn rewrite_block(
828 None
828 None
829}
829}
830
830
831−/// Move `@lang`, `@favicon`, `@canonical` and `@base` into the `@page`
831+/// Move `@lang` and `@favicon` into the `@page`
832/// line's attributes: `@page [lang en, favicon /f.png] Title`.
832/// line's attributes: `@page [lang en, favicon /f.png] Title`.
833fn fold_head_directives(input: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
833fn 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"];
835 let lines: Vec<&str> = input.lines().collect();
835 let lines: Vec<&str> = input.lines().collect();
836 let mut attrs = Vec::new();
836 let mut attrs = Vec::new();
837 let mut keep = Vec::with_capacity(lines.len());
837 let mut keep = Vec::with_capacity(lines.len());
@@ -908,6 +908,13 @@ fn rewrite_directive_line(
908 return format!("{pad}@let {}", rest);
908 return format!("{pad}@let {}", rest);
909 }
909 }
910 }
910 }
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+ }
911 if let Some(rest) = trimmed.strip_prefix("@og ")
918 if let Some(rest) = trimmed.strip_prefix("@og ")
912 && let Some((key, value)) = rest.trim().split_once(' ')
919 && let Some((key, value)) = rest.trim().split_once(' ')
913 {
920 {
@@ -1756,6 +1763,7 @@ mod tests {
1756 "@page [lang en, favicon /f.png] Home\n@text hi\n"
1763 "@page [lang en, favicon /f.png] Home\n@text hi\n"
1757 );
1764 );
1758 assert_eq!(up("@og title \"My Page\""), "@meta og:title My Page");
1765 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\">");
1759 assert_eq!(up("@debug hi $x\n@log $a $b\n@assert $x == 1\n@warn hi\n@text x"), "@text x");
1767 assert_eq!(up("@debug hi $x\n@log $a $b\n@assert $x == 1\n@warn hi\n@text x"), "@text x");
1760 assert_eq!(up("@component card $t\n @text $t"), "@let card $t\n @text $t");
1768 assert_eq!(up("@component card $t\n @text $t"), "@let card $t\n @text $t");
1761 assert_eq!(up("@el [color $on ? green : gray]"), "@el [color if($on, green, gray)]");
1769 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() {
463 assert!(out.contains("content:\"→ \""), "{}", out);
463 assert!(out.contains("content:\"→ \""), "{}", out);
464 assert!(out.contains("content:attr(title)"), "{}", out);
464 assert!(out.contains("content:attr(title)"), "{}", out);
465}
465}
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() {
3236 assert!(output.contains("<search>"), "search: {}", output);
3236 assert!(output.contains("<search>"), "search: {}", output);
3237}
3237}
3238
3238
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−
3259#[test]
3239#[test]
3260fn font_face_directive() {
3240fn font_face_directive() {
3261 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");
3241 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">
2@style
5@style
3 @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }
6 @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }
4@head
7@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/">
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">
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">
2 {
3 {
3 "@context": "https://schema.org",
4 "@context": "https://schema.org",
4 "@type": "WebPage",
5 "@type": "WebPage",