html_compose.document
HTML5 document generation functions and classes.
The most featureful way to generate a full HTML5 document is to use
the HTML5Document class with html_compose.resource types managing
imports. The benefit of this is automatic and correct ordering of resources,
preloads, and import mapping.
The most control over the document generation process is to use
document_generator which yields parts of the document as strings.
Streaming alternatives are available via document_streamer, or
HTML5Document.stream().
These will yield chunks as they are generated.
1""" 2HTML5 document generation functions and classes. 3 4The most featureful way to generate a full HTML5 document is to use 5the `HTML5Document` class with `html_compose.resource` types managing 6imports. The benefit of this is automatic and correct ordering of resources, 7preloads, and import mapping. 8 9The most control over the document generation process is to use 10`document_generator` which yields parts of the document as strings. 11 12Streaming alternatives are available via `document_streamer`, or 13`HTML5Document.stream()`. 14 15These will yield chunks as they are generated. 16""" 17 18import os 19from typing import Any, Generator, Iterable, Literal, TypeAlias 20from urllib.parse import urlencode 21 22from . import base_types, doctype, pretty_print, resource, unsafe_text 23from . import elements as el 24from .util_funcs import get_livereload_env 25 26Node: TypeAlias = base_types.Node 27 28 29def generate_head( 30 title: str | None = None, 31 js: Iterable[str | resource.js_import] | None = None, 32 css: Iterable[str | resource.css_import] | None = None, 33 fonts: Iterable[resource.font_import_manual | resource.font_import_provider] 34 | None = None, 35 extra: Iterable[Node] | None = None, 36 skip_meta: bool = False, 37) -> el.head: 38 """ 39 Generate a head element with common imports and arguments. 40 41 By default, this includes a viewport meta tag. 42 43 :param title: HTML document title 44 :param js: A list of javascript imports to include in the head 45 :param css: A list of CSS imports to include in the head 46 :param fonts: A list of font imports to include in the head 47 :param extra: Any extra elements to include at the end of the head 48 :param skip_meta: Skip the meta viewport tag 49 50 :return: A head element with the specified imports and title 51 """ 52 head_elements: list[Node] = resource.to_elements( 53 js=js, css=css, fonts=fonts 54 ) 55 if extra: 56 head_elements.extend(extra) 57 58 return el.head()[ 59 el.meta( 60 name="viewport", content="width=device-width, initial-scale=1.0" 61 ) 62 if not skip_meta 63 else None, 64 el.title()[title] if title else None, 65 head_elements, 66 ] 67 68 69def document_streamer( 70 lang: str | None = None, 71 head: Iterable[Node] | el.head | None = None, 72 body: Iterable[Node] | el.body | None = None, 73 stream_mode: Literal["head_only", "full"] = "head_only", 74) -> Generator[str, Any, None]: 75 """ 76 Return a full HTML5 document as a generator, yielding parts as strings. 77 78 stream_mode controls whether to yield just the head first, then body, 79 or the full document in parts. 80 81 tldr: 82 ``` 83 doctype("html") 84 html(lang=lang)[ 85 head[ 86 meta(name="viewport", content="width=device-width, initial-scale=1.0") 87 ..., 88 ] 89 body[body]] 90 ``` 91 92 When using livereload, an environment variable is set which adds 93 livereload-js to the head of the document. 94 95 :param lang: The language of the document. 96 English is "en", or consult HTML documentation 97 :param head: Children to add to the <head> element, 98 which already defines viewport. 99 A head element passed directly will be used unmodified. 100 :param body: A 'body' element or a list of children to add to the 'body' element 101 :param stream_mode: If set, return a generator that yields parts of the document. 102 "head_only" yields the head, then full body, 103 "full" yields the entire document in parts. 104 105 :return: A generator that yields parts of the HTML5 document as strings 106 """ 107 # Enable HTML5 and prevent quirks mode 108 header = doctype("html") 109 if isinstance(head, el.head): 110 head_el = head 111 else: 112 head_el = generate_head(extra=head) 113 # None if disabled 114 live_reload_flags = get_livereload_env() 115 # Feature: Live reloading for development 116 # Fires when HTMLCOMPOSE_LIVERELOAD=1 117 if live_reload_flags: 118 head_el.append(_livereload_script_tag(live_reload_flags)) 119 # Produce our HTML element and save its parts 120 html_el = el.html(lang=lang).resolve() 121 html_el_start = next(html_el) 122 html_el_end = next(html_el) 123 # Yield up until end of the head element 124 yield f"{header}\n{html_el_start}\n{head_el.render()}\n\n" 125 126 # Setup the body element 127 if isinstance(body, el.body): 128 body_el = body 129 else: 130 body_el = el.body()[body] 131 if stream_mode == "full": 132 # Resolve in pieces 133 for body_part in body_el.resolve(): 134 yield body_part 135 yield "\n" 136 yield html_el_end 137 elif stream_mode == "head_only": 138 # Resolve all at once 139 yield f"{body_el.render()}\n{html_el_end}" 140 else: 141 raise ValueError("stream_mode must be 'head_only' or 'full'") 142 143 144def document_generator( 145 lang: str | None = None, 146 head: el.head | list | None = None, 147 body: Iterable[Node] | el.body | None = None, 148) -> str: 149 """ 150 Return a full HTML5 document as a string. 151 152 tldr: 153 ``` 154 doctype("html") 155 html(lang=lang)[ 156 head[ 157 meta(name="viewport", content="width=device-width, initial-scale=1.0") 158 title(title) 159 ] 160 body[body]] 161 ``` 162 163 When using livereload, an environment variable is set which adds 164 livereload-js to the head of the document. 165 166 167 :param lang: The language of the document. 168 English is "en", or consult HTML documentation 169 :param head: Children to add to the <head> element, 170 which already defines viewport. 171 A head element passed directly will be used unmodified. 172 :param body: A 'body' element or a list of children to add to the 'body' element 173 174 :return: A full HTML5 document as a string 175 176 """ 177 return "".join( 178 document_streamer(lang=lang, head=head, body=body, stream_mode="full") 179 ) 180 181 182def get_livereload_uri() -> str: 183 """ 184 Return livereload-js compatible resource URI 185 But if the user wants they can override this function to return a local 186 resource i.e. 187 188 html_compose.document.get_live_reload_uri = 189 lambda: "mydomain.com/static/livereload.js"; 190 191 Or just set env var HTMLCOMPOSE_LIVERELOAD_URL to the desired URL. 192 193 We default to livereload-morph, which fits our use case better. 194 195 """ 196 env_url = os.getenv("HTMLCOMPOSE_LIVERELOAD_URL", None) 197 # allow user override. 198 if env_url: 199 return env_url 200 VERSION = "0.3.0" 201 # We ported from livereload-js to livereload-morph to better fit our use case 202 return f"cdn.jsdelivr.net/npm/livereload-morph@{VERSION}/dist/livereload-morph.js" 203 204 205def _livereload_script_tag(live_reload_settings): 206 """ 207 Returns a script tag which injects livereload.js. 208 """ 209 # Fires when HTMLCOMPOSE_LIVERELOAD=1 210 # Livereload: https://github.com/livereload/livereload-js 211 uri = get_livereload_uri() 212 213 proxy_uri = live_reload_settings["proxy_uri"] 214 proxy_host = live_reload_settings["proxy_host"] 215 if proxy_host: 216 # Websocket is behind a proxy, likely SSL 217 # Port isn't important for these but the URI is 218 if proxy_uri.startswith("/"): 219 proxy_uri = proxy_uri.lstrip("/") 220 uri_encoded_flags = urlencode({"host": proxy_host, "path": proxy_uri}) 221 else: 222 # Regular development enviroment with no proxy. host:port will do. 223 host = live_reload_settings["host"] 224 port = live_reload_settings["port"] 225 uri_encoded_flags = urlencode( 226 {"host": host, "port": port, "verbose": True} 227 ) 228 229 # This scriptlet auto-inserts the livereload script and detects protocol 230 return el.script()[ 231 unsafe_text( 232 "\n".join( 233 [ 234 "(function(){", 235 'var s = document.createElement("script");', 236 f"s.src = location.protocol + '//{uri}?{uri_encoded_flags}';", 237 "document.head.appendChild(s)", 238 "})()", 239 ] 240 ) 241 ) 242 ] 243 244 245class HTML5Document: 246 """ 247 A convenience class to generate a full HTML5 document. 248 249 Allows you to specify common elements like JavaScript and CSS imports, 250 as well as additional head content. 251 252 When using livereload, an environment variable is set which adds 253 livereload-js to the head of the document. 254 """ 255 256 def __init__( 257 self, 258 title: str | None = None, 259 lang: str | None = None, 260 js: Iterable[str | resource.js_import] | None = None, 261 css: Iterable[str | resource.css_import] | None = None, 262 fonts: Iterable[ 263 resource.font_import_manual | resource.font_import_provider 264 ] 265 | None = None, 266 head_extra: Iterable[Node] | None = None, 267 body: Iterable[Node] | el.body | None = None, 268 ) -> None: 269 """ 270 271 :param title: The title of the document 272 :param lang: The language of the document. 273 English is "en", or consult HTML documentation 274 :param js: A list of javascript imports to include in the head 275 :param css: A list of CSS imports to include in the head 276 :param fonts: A list of font imports to include in the head 277 :param head_extra: Additional elements to include in the head 278 :param body: A 'body' element or a list of elements to include in the body 279 :param stream_mode: If set, return a generator that yields parts of the document. 280 "head_only" yields the head, then full body, 281 "full" yields the entire document in parts. 282 """ 283 self.title = title 284 self.lang = lang 285 self.js = js 286 self.css = css 287 self.fonts = fonts 288 self.head_extra = head_extra 289 if isinstance(body, el.body): 290 self.body = body 291 else: 292 self.body = el.body()[body] 293 294 def render(self) -> str: 295 """ 296 Return the full HTML5 document as a string. 297 """ 298 return "".join(self.stream(stream_mode="full")) 299 300 def stream( 301 self, stream_mode: Literal["head_only", "full"] = "head_only" 302 ) -> Generator[str, Any, None]: 303 """ 304 Return a generator that yields parts of the HTML5 document as strings. 305 306 :param stream_mode: Parts of the document to stream. If "head_only", 307 we yield the head, then the full body. 308 If "full", we yield the entire document in parts. 309 310 :return: A generator that yields parts of the HTML5 document as strings. 311 """ 312 return document_streamer( 313 lang=self.lang, 314 head=generate_head( 315 title=self.title, 316 js=self.js, 317 css=self.css, 318 fonts=self.fonts, 319 extra=self.head_extra, 320 ), 321 body=self.body, 322 stream_mode=stream_mode, 323 ) 324 325 def __html__(self) -> str: 326 return self.render() 327 328 def __str__(self) -> str: 329 return self.render() 330 331 def __repr__(self) -> str: 332 return pretty_print(str(self))
30def generate_head( 31 title: str | None = None, 32 js: Iterable[str | resource.js_import] | None = None, 33 css: Iterable[str | resource.css_import] | None = None, 34 fonts: Iterable[resource.font_import_manual | resource.font_import_provider] 35 | None = None, 36 extra: Iterable[Node] | None = None, 37 skip_meta: bool = False, 38) -> el.head: 39 """ 40 Generate a head element with common imports and arguments. 41 42 By default, this includes a viewport meta tag. 43 44 :param title: HTML document title 45 :param js: A list of javascript imports to include in the head 46 :param css: A list of CSS imports to include in the head 47 :param fonts: A list of font imports to include in the head 48 :param extra: Any extra elements to include at the end of the head 49 :param skip_meta: Skip the meta viewport tag 50 51 :return: A head element with the specified imports and title 52 """ 53 head_elements: list[Node] = resource.to_elements( 54 js=js, css=css, fonts=fonts 55 ) 56 if extra: 57 head_elements.extend(extra) 58 59 return el.head()[ 60 el.meta( 61 name="viewport", content="width=device-width, initial-scale=1.0" 62 ) 63 if not skip_meta 64 else None, 65 el.title()[title] if title else None, 66 head_elements, 67 ]
Generate a head element with common imports and arguments.
By default, this includes a viewport meta tag.
Parameters
- title: HTML document title
- js: A list of javascript imports to include in the head
- css: A list of CSS imports to include in the head
- fonts: A list of font imports to include in the head
- extra: Any extra elements to include at the end of the head
- skip_meta: Skip the meta viewport tag
Returns
A head element with the specified imports and title
70def document_streamer( 71 lang: str | None = None, 72 head: Iterable[Node] | el.head | None = None, 73 body: Iterable[Node] | el.body | None = None, 74 stream_mode: Literal["head_only", "full"] = "head_only", 75) -> Generator[str, Any, None]: 76 """ 77 Return a full HTML5 document as a generator, yielding parts as strings. 78 79 stream_mode controls whether to yield just the head first, then body, 80 or the full document in parts. 81 82 tldr: 83 ``` 84 doctype("html") 85 html(lang=lang)[ 86 head[ 87 meta(name="viewport", content="width=device-width, initial-scale=1.0") 88 ..., 89 ] 90 body[body]] 91 ``` 92 93 When using livereload, an environment variable is set which adds 94 livereload-js to the head of the document. 95 96 :param lang: The language of the document. 97 English is "en", or consult HTML documentation 98 :param head: Children to add to the <head> element, 99 which already defines viewport. 100 A head element passed directly will be used unmodified. 101 :param body: A 'body' element or a list of children to add to the 'body' element 102 :param stream_mode: If set, return a generator that yields parts of the document. 103 "head_only" yields the head, then full body, 104 "full" yields the entire document in parts. 105 106 :return: A generator that yields parts of the HTML5 document as strings 107 """ 108 # Enable HTML5 and prevent quirks mode 109 header = doctype("html") 110 if isinstance(head, el.head): 111 head_el = head 112 else: 113 head_el = generate_head(extra=head) 114 # None if disabled 115 live_reload_flags = get_livereload_env() 116 # Feature: Live reloading for development 117 # Fires when HTMLCOMPOSE_LIVERELOAD=1 118 if live_reload_flags: 119 head_el.append(_livereload_script_tag(live_reload_flags)) 120 # Produce our HTML element and save its parts 121 html_el = el.html(lang=lang).resolve() 122 html_el_start = next(html_el) 123 html_el_end = next(html_el) 124 # Yield up until end of the head element 125 yield f"{header}\n{html_el_start}\n{head_el.render()}\n\n" 126 127 # Setup the body element 128 if isinstance(body, el.body): 129 body_el = body 130 else: 131 body_el = el.body()[body] 132 if stream_mode == "full": 133 # Resolve in pieces 134 for body_part in body_el.resolve(): 135 yield body_part 136 yield "\n" 137 yield html_el_end 138 elif stream_mode == "head_only": 139 # Resolve all at once 140 yield f"{body_el.render()}\n{html_el_end}" 141 else: 142 raise ValueError("stream_mode must be 'head_only' or 'full'")
Return a full HTML5 document as a generator, yielding parts as strings.
stream_mode controls whether to yield just the head first, then body, or the full document in parts.
tldr:
doctype("html")
html(lang=lang)[
head[
meta(name="viewport", content="width=device-width, initial-scale=1.0")
...,
]
body[body]]
When using livereload, an environment variable is set which adds livereload-js to the head of the document.
Parameters
- lang: The language of the document. English is "en", or consult HTML documentation
- head: Children to add to the element, which already defines viewport. A head element passed directly will be used unmodified.
- body: A 'body' element or a list of children to add to the 'body' element
- stream_mode: If set, return a generator that yields parts of the document. "head_only" yields the head, then full body, "full" yields the entire document in parts.
Returns
A generator that yields parts of the HTML5 document as strings
145def document_generator( 146 lang: str | None = None, 147 head: el.head | list | None = None, 148 body: Iterable[Node] | el.body | None = None, 149) -> str: 150 """ 151 Return a full HTML5 document as a string. 152 153 tldr: 154 ``` 155 doctype("html") 156 html(lang=lang)[ 157 head[ 158 meta(name="viewport", content="width=device-width, initial-scale=1.0") 159 title(title) 160 ] 161 body[body]] 162 ``` 163 164 When using livereload, an environment variable is set which adds 165 livereload-js to the head of the document. 166 167 168 :param lang: The language of the document. 169 English is "en", or consult HTML documentation 170 :param head: Children to add to the <head> element, 171 which already defines viewport. 172 A head element passed directly will be used unmodified. 173 :param body: A 'body' element or a list of children to add to the 'body' element 174 175 :return: A full HTML5 document as a string 176 177 """ 178 return "".join( 179 document_streamer(lang=lang, head=head, body=body, stream_mode="full") 180 )
Return a full HTML5 document as a string.
tldr:
doctype("html")
html(lang=lang)[
head[
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title(title)
]
body[body]]
When using livereload, an environment variable is set which adds livereload-js to the head of the document.
Parameters
- lang: The language of the document. English is "en", or consult HTML documentation
- head: Children to add to the element, which already defines viewport. A head element passed directly will be used unmodified.
- body: A 'body' element or a list of children to add to the 'body' element
Returns
A full HTML5 document as a string
183def get_livereload_uri() -> str: 184 """ 185 Return livereload-js compatible resource URI 186 But if the user wants they can override this function to return a local 187 resource i.e. 188 189 html_compose.document.get_live_reload_uri = 190 lambda: "mydomain.com/static/livereload.js"; 191 192 Or just set env var HTMLCOMPOSE_LIVERELOAD_URL to the desired URL. 193 194 We default to livereload-morph, which fits our use case better. 195 196 """ 197 env_url = os.getenv("HTMLCOMPOSE_LIVERELOAD_URL", None) 198 # allow user override. 199 if env_url: 200 return env_url 201 VERSION = "0.3.0" 202 # We ported from livereload-js to livereload-morph to better fit our use case 203 return f"cdn.jsdelivr.net/npm/livereload-morph@{VERSION}/dist/livereload-morph.js"
Return livereload-js compatible resource URI But if the user wants they can override this function to return a local resource i.e.
html_compose.document.get_live_reload_uri = lambda: "mydomain.com/static/livereload.js";
Or just set env var HTMLCOMPOSE_LIVERELOAD_URL to the desired URL.
We default to livereload-morph, which fits our use case better.
246class HTML5Document: 247 """ 248 A convenience class to generate a full HTML5 document. 249 250 Allows you to specify common elements like JavaScript and CSS imports, 251 as well as additional head content. 252 253 When using livereload, an environment variable is set which adds 254 livereload-js to the head of the document. 255 """ 256 257 def __init__( 258 self, 259 title: str | None = None, 260 lang: str | None = None, 261 js: Iterable[str | resource.js_import] | None = None, 262 css: Iterable[str | resource.css_import] | None = None, 263 fonts: Iterable[ 264 resource.font_import_manual | resource.font_import_provider 265 ] 266 | None = None, 267 head_extra: Iterable[Node] | None = None, 268 body: Iterable[Node] | el.body | None = None, 269 ) -> None: 270 """ 271 272 :param title: The title of the document 273 :param lang: The language of the document. 274 English is "en", or consult HTML documentation 275 :param js: A list of javascript imports to include in the head 276 :param css: A list of CSS imports to include in the head 277 :param fonts: A list of font imports to include in the head 278 :param head_extra: Additional elements to include in the head 279 :param body: A 'body' element or a list of elements to include in the body 280 :param stream_mode: If set, return a generator that yields parts of the document. 281 "head_only" yields the head, then full body, 282 "full" yields the entire document in parts. 283 """ 284 self.title = title 285 self.lang = lang 286 self.js = js 287 self.css = css 288 self.fonts = fonts 289 self.head_extra = head_extra 290 if isinstance(body, el.body): 291 self.body = body 292 else: 293 self.body = el.body()[body] 294 295 def render(self) -> str: 296 """ 297 Return the full HTML5 document as a string. 298 """ 299 return "".join(self.stream(stream_mode="full")) 300 301 def stream( 302 self, stream_mode: Literal["head_only", "full"] = "head_only" 303 ) -> Generator[str, Any, None]: 304 """ 305 Return a generator that yields parts of the HTML5 document as strings. 306 307 :param stream_mode: Parts of the document to stream. If "head_only", 308 we yield the head, then the full body. 309 If "full", we yield the entire document in parts. 310 311 :return: A generator that yields parts of the HTML5 document as strings. 312 """ 313 return document_streamer( 314 lang=self.lang, 315 head=generate_head( 316 title=self.title, 317 js=self.js, 318 css=self.css, 319 fonts=self.fonts, 320 extra=self.head_extra, 321 ), 322 body=self.body, 323 stream_mode=stream_mode, 324 ) 325 326 def __html__(self) -> str: 327 return self.render() 328 329 def __str__(self) -> str: 330 return self.render() 331 332 def __repr__(self) -> str: 333 return pretty_print(str(self))
A convenience class to generate a full HTML5 document.
Allows you to specify common elements like JavaScript and CSS imports, as well as additional head content.
When using livereload, an environment variable is set which adds livereload-js to the head of the document.
257 def __init__( 258 self, 259 title: str | None = None, 260 lang: str | None = None, 261 js: Iterable[str | resource.js_import] | None = None, 262 css: Iterable[str | resource.css_import] | None = None, 263 fonts: Iterable[ 264 resource.font_import_manual | resource.font_import_provider 265 ] 266 | None = None, 267 head_extra: Iterable[Node] | None = None, 268 body: Iterable[Node] | el.body | None = None, 269 ) -> None: 270 """ 271 272 :param title: The title of the document 273 :param lang: The language of the document. 274 English is "en", or consult HTML documentation 275 :param js: A list of javascript imports to include in the head 276 :param css: A list of CSS imports to include in the head 277 :param fonts: A list of font imports to include in the head 278 :param head_extra: Additional elements to include in the head 279 :param body: A 'body' element or a list of elements to include in the body 280 :param stream_mode: If set, return a generator that yields parts of the document. 281 "head_only" yields the head, then full body, 282 "full" yields the entire document in parts. 283 """ 284 self.title = title 285 self.lang = lang 286 self.js = js 287 self.css = css 288 self.fonts = fonts 289 self.head_extra = head_extra 290 if isinstance(body, el.body): 291 self.body = body 292 else: 293 self.body = el.body()[body]
Parameters
- title: The title of the document
- lang: The language of the document. English is "en", or consult HTML documentation
- js: A list of javascript imports to include in the head
- css: A list of CSS imports to include in the head
- fonts: A list of font imports to include in the head
- head_extra: Additional elements to include in the head
- body: A 'body' element or a list of elements to include in the body
- stream_mode: If set, return a generator that yields parts of the document. "head_only" yields the head, then full body, "full" yields the entire document in parts.
295 def render(self) -> str: 296 """ 297 Return the full HTML5 document as a string. 298 """ 299 return "".join(self.stream(stream_mode="full"))
Return the full HTML5 document as a string.
301 def stream( 302 self, stream_mode: Literal["head_only", "full"] = "head_only" 303 ) -> Generator[str, Any, None]: 304 """ 305 Return a generator that yields parts of the HTML5 document as strings. 306 307 :param stream_mode: Parts of the document to stream. If "head_only", 308 we yield the head, then the full body. 309 If "full", we yield the entire document in parts. 310 311 :return: A generator that yields parts of the HTML5 document as strings. 312 """ 313 return document_streamer( 314 lang=self.lang, 315 head=generate_head( 316 title=self.title, 317 js=self.js, 318 css=self.css, 319 fonts=self.fonts, 320 extra=self.head_extra, 321 ), 322 body=self.body, 323 stream_mode=stream_mode, 324 )
Return a generator that yields parts of the HTML5 document as strings.
Parameters
- stream_mode: Parts of the document to stream. If "head_only", we yield the head, then the full body. If "full", we yield the entire document in parts.
Returns
A generator that yields parts of the HTML5 document as strings.