lhtml-markup 2.4.1__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/PKG-INFO +91 -2
  2. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/README.md +90 -1
  3. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/__init__.py +6 -4
  4. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/cli.py +11 -0
  5. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/errors.py +4 -0
  6. lhtml_markup-2.5.0/src/lhtml/macros.py +276 -0
  7. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/pipeline.py +21 -2
  8. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/process.py +2 -1
  9. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/PKG-INFO +91 -2
  10. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/SOURCES.txt +1 -0
  11. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/test/test_lhtml.py +219 -0
  12. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/LICENSE.md +0 -0
  13. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/pyproject.toml +0 -0
  14. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/setup.cfg +0 -0
  15. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/__main__.py +0 -0
  16. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/code.py +0 -0
  17. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/element_extract.py +0 -0
  18. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/export_html.py +0 -0
  19. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/insert_in_text.py +0 -0
  20. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/listing.py +0 -0
  21. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/patterns.py +0 -0
  22. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/tag_element.lark +0 -0
  23. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/tag_parser.py +0 -0
  24. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/wrap_html.py +0 -0
  25. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/dependency_links.txt +0 -0
  26. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/entry_points.txt +0 -0
  27. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/requires.txt +0 -0
  28. {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lhtml-markup
3
- Version: 2.4.1
3
+ Version: 2.5.0
4
4
  Summary: Lightweight HTML markup language — simplifies HTML authoring with shorthand syntax
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/drohmer/lhtml
@@ -62,6 +62,7 @@ lhtml input.l.html # Convert to stdout
62
62
  lhtml input.l.html -o output.html # Convert to file
63
63
  lhtml input.l.html -w # Wrap in full HTML document (--wrapAuto)
64
64
  lhtml input.l.html -b # Render source line breaks as <br> (--line-breaks)
65
+ lhtml input.l.html -m macros.yaml # Declare macros, custom :: tags (--macros)
65
66
  lhtml a.l.html b.l.html # Several files: a.html, b.html next to sources
66
67
  lhtml a.l.html b.l.html -o build/ # Several files into a directory
67
68
  python -m lhtml input.l.html # Alternative invocation
@@ -112,6 +113,12 @@ html = lhtml.run(text, {
112
113
  'css': ['style.css'],
113
114
  'js': ['script.js'],
114
115
  })
116
+
117
+ # Macros (custom :: tags, see Macros below)
118
+ html = lhtml.run('box:: x ::\n', {'macros': {'box': {'class': 'box'}}})
119
+ macros = lhtml.load_macros('macros.yaml') # definitions from files or dicts
120
+ registry = lhtml.registry_with_macros(macros) # validates them (LHTMLMacroError)
121
+ info = lhtml.describe_macro('box', macros['box']) # what a definition renders, for docs
115
122
  ```
116
123
 
117
124
 
@@ -414,6 +421,85 @@ include::components/nav.html
414
421
  Included files are recursively processed (up to 20 levels); their own YAML front matter is ignored. An included file looks for its own includes first in its own directory, then in `directory_include`. A circular include raises `LHTMLIncludeLoopError`.
415
422
 
416
423
 
424
+ ### Macros (custom tags from a configuration)
425
+
426
+ A macro is a named `::` tag declared in YAML (or a dict): a shortcut for an
427
+ HTML element with default classes, style and attributes. The styling stays
428
+ in CSS, so a deck or a site changes its look in one place.
429
+
430
+ ```yaml
431
+ # macros.yaml (the definitions may also be at the top level)
432
+ macros:
433
+ small: {class: small}
434
+ credit: {tag: span, class: credit}
435
+ aside: {class: aside}
436
+ box: {class: box}
437
+ gap: {class: gap, empty: true, variant: [s, m, l], default: m}
438
+ demo: {tag: iframe, class: demo, empty: true, url: src, attrs: 'frameborder="0"'}
439
+ ```
440
+
441
+ ```
442
+ aside::[top:400px;]
443
+ img::assets/figure.png[width:450px;]
444
+ ::
445
+
446
+ box::(.good) **Correct** ::
447
+ gap::l
448
+ credit:: Image: Wikimedia Commons ::
449
+ demo::assets/demo/index.html
450
+ ```
451
+
452
+ Output:
453
+ ```html
454
+ <div class="aside" style="top:400px;">
455
+ <img style="width:450px;" src="assets/figure.png" alt="assets/figure.png">
456
+ </div>
457
+
458
+ <div class="box good"> <strong>Correct</strong> </div>
459
+ <div class="gap gap-l"></div>
460
+ <span class="credit"> Image: Wikimedia Commons </span>
461
+ <iframe class="demo" src="assets/demo/index.html" frameborder="0"></iframe>
462
+ ```
463
+
464
+ | Field | Description |
465
+ |-------|-------------|
466
+ | `tag` | HTML element (default `div`) |
467
+ | `class` | Default classes (space separated) |
468
+ | `style` | Default inline style, placed before the style of the source |
469
+ | `attrs` | Default HTML attributes |
470
+ | `empty` | No content: the element is closed at once (`gap::`, `demo::url`) |
471
+ | `url` | The text after `::` is the value of this attribute (`src`, `data-src`, ...). Not needed for `tag: img`, `video`, `videoplay`, `link`: their text is already the URL |
472
+ | `variant` | The text after `::` selects a variant, added as the class `<first class>-<variant>`; a list restricts the allowed values (an unknown one gives a warning) |
473
+ | `default` | Variant used when none is given (one of the variants) |
474
+ | `doc` | Description, ignored by LHTML (for documentation tools; `lhtml.describe_macro(name, spec)` gives what a definition renders) |
475
+
476
+ The classes, style and attributes written in the source are added to the
477
+ defaults, then the element is rendered like any element: a macro whose `tag`
478
+ is an LHTML tag (`img`, `video`, `videoplay`, `link`, ...) uses that tag
479
+ (`photo: {tag: img, class: photo}` gives `photo::a.png` an `alt`, `videoplay`
480
+ keeps its codec variants, the `style` of a `link` macro is an attribute since
481
+ `[]` is the link text); a macro never renders through another macro. A macro is closed by `::` or by its name (`::box[-]`). Macros are
482
+ not active inside inline code (`` `box::` `` stays text), and a macro cannot
483
+ replace a built-in tag (`div`, `img`, ...). Unlike `img::`, the URL of a `url`
484
+ macro is not protected from inline formatting (avoid `__` in it).
485
+
486
+ A variant may be closed at once like any element: `gap::l::`.
487
+
488
+ Declare them with `-m` / `--macros` (repeatable, later files win), with the
489
+ `macros` key of the front matter (file names relative to the page, or
490
+ definitions), or with `lhtml.run(text, {'macros': ...})` (a dict, a file
491
+ name, or a list of them). The macros of the front matter are added to the
492
+ others (a macro of the same name is replaced, `name: null` removes it). A
493
+ file holds the definitions at its top level or under its only key `macros`
494
+ (`macros` is thus not a macro name). An HTML void element
495
+ (`tag: hr`, `br`, ...) is always empty. Invalid definitions (unknown field,
496
+ wrong type) raise `LHTMLMacroError`.
497
+
498
+ ```bash
499
+ lhtml -m macros.yaml slide.l.html
500
+ ```
501
+
502
+
417
503
  ### YAML Front Matter
418
504
 
419
505
  The front matter must be at the very beginning of the file (`---` separators elsewhere are kept as text). Input text is normalized first: a leading BOM is removed and CRLF line endings become LF.
@@ -439,6 +525,7 @@ Supported metadata keys:
439
525
  | `wrap-auto` | boolean | Wrap output in full HTML document |
440
526
  | `line-breaks` | boolean | Render source line breaks as `<br>` |
441
527
  | `directory_include` | list | Directories to search for includes |
528
+ | `macros` | string, list or mapping | Macros: YAML file(s) relative to the page, or definitions (see [Macros](#macros-custom-tags-from-a-configuration)) |
442
529
 
443
530
 
444
531
  ## Using LHTML with Jinja2
@@ -553,6 +640,7 @@ All keys for the `meta` dict passed to `lhtml.run()`:
553
640
  'js': [], # JS files (string or list)
554
641
  'directory_include': [cwd], # Search paths for include:: (default: current directory)
555
642
  'current_directory': '', # Base directory for video codec detection
643
+ 'macros': None, # Macros: dict, YAML file name, or list of them
556
644
  }
557
645
  ```
558
646
 
@@ -571,10 +659,11 @@ All keys for the `meta` dict passed to `lhtml.run()`:
571
659
 
572
660
  ```
573
661
  src/lhtml/
574
- __init__.py # Public API: run(), analyse_tag(), read_yaml()
662
+ __init__.py # Public API: run(), analyse_tag(), read_yaml(), macro functions
575
663
  __main__.py # python -m lhtml
576
664
  cli.py # Command-line interface
577
665
  pipeline.py # ProcessingPipeline, TagRegistry, LexerRegistry
666
+ macros.py # Macros: custom :: tags declared in YAML
578
667
  process.py # Core transformation functions
579
668
  patterns.py # Centralized regex patterns and utilities
580
669
  tag_parser.py # Lark-based parser for :: bracket syntax
@@ -43,6 +43,7 @@ lhtml input.l.html # Convert to stdout
43
43
  lhtml input.l.html -o output.html # Convert to file
44
44
  lhtml input.l.html -w # Wrap in full HTML document (--wrapAuto)
45
45
  lhtml input.l.html -b # Render source line breaks as <br> (--line-breaks)
46
+ lhtml input.l.html -m macros.yaml # Declare macros, custom :: tags (--macros)
46
47
  lhtml a.l.html b.l.html # Several files: a.html, b.html next to sources
47
48
  lhtml a.l.html b.l.html -o build/ # Several files into a directory
48
49
  python -m lhtml input.l.html # Alternative invocation
@@ -93,6 +94,12 @@ html = lhtml.run(text, {
93
94
  'css': ['style.css'],
94
95
  'js': ['script.js'],
95
96
  })
97
+
98
+ # Macros (custom :: tags, see Macros below)
99
+ html = lhtml.run('box:: x ::\n', {'macros': {'box': {'class': 'box'}}})
100
+ macros = lhtml.load_macros('macros.yaml') # definitions from files or dicts
101
+ registry = lhtml.registry_with_macros(macros) # validates them (LHTMLMacroError)
102
+ info = lhtml.describe_macro('box', macros['box']) # what a definition renders, for docs
96
103
  ```
97
104
 
98
105
 
@@ -395,6 +402,85 @@ include::components/nav.html
395
402
  Included files are recursively processed (up to 20 levels); their own YAML front matter is ignored. An included file looks for its own includes first in its own directory, then in `directory_include`. A circular include raises `LHTMLIncludeLoopError`.
396
403
 
397
404
 
405
+ ### Macros (custom tags from a configuration)
406
+
407
+ A macro is a named `::` tag declared in YAML (or a dict): a shortcut for an
408
+ HTML element with default classes, style and attributes. The styling stays
409
+ in CSS, so a deck or a site changes its look in one place.
410
+
411
+ ```yaml
412
+ # macros.yaml (the definitions may also be at the top level)
413
+ macros:
414
+ small: {class: small}
415
+ credit: {tag: span, class: credit}
416
+ aside: {class: aside}
417
+ box: {class: box}
418
+ gap: {class: gap, empty: true, variant: [s, m, l], default: m}
419
+ demo: {tag: iframe, class: demo, empty: true, url: src, attrs: 'frameborder="0"'}
420
+ ```
421
+
422
+ ```
423
+ aside::[top:400px;]
424
+ img::assets/figure.png[width:450px;]
425
+ ::
426
+
427
+ box::(.good) **Correct** ::
428
+ gap::l
429
+ credit:: Image: Wikimedia Commons ::
430
+ demo::assets/demo/index.html
431
+ ```
432
+
433
+ Output:
434
+ ```html
435
+ <div class="aside" style="top:400px;">
436
+ <img style="width:450px;" src="assets/figure.png" alt="assets/figure.png">
437
+ </div>
438
+
439
+ <div class="box good"> <strong>Correct</strong> </div>
440
+ <div class="gap gap-l"></div>
441
+ <span class="credit"> Image: Wikimedia Commons </span>
442
+ <iframe class="demo" src="assets/demo/index.html" frameborder="0"></iframe>
443
+ ```
444
+
445
+ | Field | Description |
446
+ |-------|-------------|
447
+ | `tag` | HTML element (default `div`) |
448
+ | `class` | Default classes (space separated) |
449
+ | `style` | Default inline style, placed before the style of the source |
450
+ | `attrs` | Default HTML attributes |
451
+ | `empty` | No content: the element is closed at once (`gap::`, `demo::url`) |
452
+ | `url` | The text after `::` is the value of this attribute (`src`, `data-src`, ...). Not needed for `tag: img`, `video`, `videoplay`, `link`: their text is already the URL |
453
+ | `variant` | The text after `::` selects a variant, added as the class `<first class>-<variant>`; a list restricts the allowed values (an unknown one gives a warning) |
454
+ | `default` | Variant used when none is given (one of the variants) |
455
+ | `doc` | Description, ignored by LHTML (for documentation tools; `lhtml.describe_macro(name, spec)` gives what a definition renders) |
456
+
457
+ The classes, style and attributes written in the source are added to the
458
+ defaults, then the element is rendered like any element: a macro whose `tag`
459
+ is an LHTML tag (`img`, `video`, `videoplay`, `link`, ...) uses that tag
460
+ (`photo: {tag: img, class: photo}` gives `photo::a.png` an `alt`, `videoplay`
461
+ keeps its codec variants, the `style` of a `link` macro is an attribute since
462
+ `[]` is the link text); a macro never renders through another macro. A macro is closed by `::` or by its name (`::box[-]`). Macros are
463
+ not active inside inline code (`` `box::` `` stays text), and a macro cannot
464
+ replace a built-in tag (`div`, `img`, ...). Unlike `img::`, the URL of a `url`
465
+ macro is not protected from inline formatting (avoid `__` in it).
466
+
467
+ A variant may be closed at once like any element: `gap::l::`.
468
+
469
+ Declare them with `-m` / `--macros` (repeatable, later files win), with the
470
+ `macros` key of the front matter (file names relative to the page, or
471
+ definitions), or with `lhtml.run(text, {'macros': ...})` (a dict, a file
472
+ name, or a list of them). The macros of the front matter are added to the
473
+ others (a macro of the same name is replaced, `name: null` removes it). A
474
+ file holds the definitions at its top level or under its only key `macros`
475
+ (`macros` is thus not a macro name). An HTML void element
476
+ (`tag: hr`, `br`, ...) is always empty. Invalid definitions (unknown field,
477
+ wrong type) raise `LHTMLMacroError`.
478
+
479
+ ```bash
480
+ lhtml -m macros.yaml slide.l.html
481
+ ```
482
+
483
+
398
484
  ### YAML Front Matter
399
485
 
400
486
  The front matter must be at the very beginning of the file (`---` separators elsewhere are kept as text). Input text is normalized first: a leading BOM is removed and CRLF line endings become LF.
@@ -420,6 +506,7 @@ Supported metadata keys:
420
506
  | `wrap-auto` | boolean | Wrap output in full HTML document |
421
507
  | `line-breaks` | boolean | Render source line breaks as `<br>` |
422
508
  | `directory_include` | list | Directories to search for includes |
509
+ | `macros` | string, list or mapping | Macros: YAML file(s) relative to the page, or definitions (see [Macros](#macros-custom-tags-from-a-configuration)) |
423
510
 
424
511
 
425
512
  ## Using LHTML with Jinja2
@@ -534,6 +621,7 @@ All keys for the `meta` dict passed to `lhtml.run()`:
534
621
  'js': [], # JS files (string or list)
535
622
  'directory_include': [cwd], # Search paths for include:: (default: current directory)
536
623
  'current_directory': '', # Base directory for video codec detection
624
+ 'macros': None, # Macros: dict, YAML file name, or list of them
537
625
  }
538
626
  ```
539
627
 
@@ -552,10 +640,11 @@ All keys for the `meta` dict passed to `lhtml.run()`:
552
640
 
553
641
  ```
554
642
  src/lhtml/
555
- __init__.py # Public API: run(), analyse_tag(), read_yaml()
643
+ __init__.py # Public API: run(), analyse_tag(), read_yaml(), macro functions
556
644
  __main__.py # python -m lhtml
557
645
  cli.py # Command-line interface
558
646
  pipeline.py # ProcessingPipeline, TagRegistry, LexerRegistry
647
+ macros.py # Macros: custom :: tags declared in YAML
559
648
  process.py # Core transformation functions
560
649
  patterns.py # Centralized regex patterns and utilities
561
650
  tag_parser.py # Lark-based parser for :: bracket syntax
@@ -9,7 +9,7 @@ Usage:
9
9
  html = lhtml.run(text, {'wrap-auto': True, 'title': 'My Page'})
10
10
  """
11
11
 
12
- __version__ = '2.4.1'
12
+ __version__ = '2.5.0'
13
13
 
14
14
  from .element_extract import extract_bracket_elements
15
15
  from .insert_in_text import insert_element_from_index, remove_element_to_index
@@ -26,10 +26,11 @@ from .process import (
26
26
 
27
27
  from .errors import (
28
28
  LHTMLError, LHTMLParseError, LHTMLFileNotFound,
29
- LHTMLTagStackError, LHTMLIncludeLoopError, LHTMLWarning,
29
+ LHTMLTagStackError, LHTMLIncludeLoopError, LHTMLWarning, LHTMLMacroError,
30
30
  )
31
31
 
32
32
  from .pipeline import ProcessingPipeline, tag_registry, lexer_registry
33
+ from .macros import describe_macro, load_macros, register_macros, registry_with_macros
33
34
 
34
35
 
35
36
  # ---------------------------------------------------------------------------
@@ -45,7 +46,7 @@ def run(text, meta_arg=None):
45
46
  Args:
46
47
  text: LHTML markup string.
47
48
  meta_arg: Optional dict overriding default configuration.
48
- Keys: wrap-auto, title, css, js, directory_include, etc.
49
+ Keys: wrap-auto, title, css, js, directory_include, macros, etc.
49
50
 
50
51
  Returns:
51
52
  HTML string.
@@ -85,7 +86,8 @@ __all__ = [
85
86
  'wrap_auto',
86
87
  # Pipeline & plugins
87
88
  'ProcessingPipeline', 'tag_registry', 'lexer_registry',
89
+ 'describe_macro', 'load_macros', 'register_macros', 'registry_with_macros',
88
90
  # Errors
89
91
  'LHTMLError', 'LHTMLParseError', 'LHTMLFileNotFound',
90
- 'LHTMLTagStackError', 'LHTMLIncludeLoopError', 'LHTMLWarning',
92
+ 'LHTMLTagStackError', 'LHTMLIncludeLoopError', 'LHTMLWarning', 'LHTMLMacroError',
91
93
  ]
@@ -6,6 +6,7 @@ Usage:
6
6
  lhtml [-w] a.l.html b.l.html # Multiple files → .html next to sources
7
7
  lhtml [-w] a.l.html b.l.html -o build/ # Multiple files → output directory
8
8
  lhtml -b file.l.html # Source line breaks rendered as <br>
9
+ lhtml -m macros.yaml file.l.html # Custom :: tags declared in a YAML file
9
10
  lhtml --version
10
11
  python -m lhtml [same options]
11
12
  """
@@ -17,6 +18,7 @@ import warnings
17
18
 
18
19
  from . import __version__
19
20
  from .errors import LHTMLError
21
+ from .macros import load_macros, registry_with_macros
20
22
  from .pipeline import ProcessingPipeline
21
23
  from .process import read_source
22
24
 
@@ -84,6 +86,8 @@ def main():
84
86
  parser.add_argument('-b', '--line-breaks',
85
87
  help='Render the line breaks of the source text as <br>',
86
88
  action='store_true')
89
+ parser.add_argument('-m', '--macros', action='append', metavar='FILE',
90
+ help='YAML file of macros (custom :: tags); may be repeated')
87
91
  parser.add_argument('-o', '--output',
88
92
  help='Output file (single input) or directory (multiple inputs)')
89
93
  parser.add_argument('-V', '--version', action='version',
@@ -95,6 +99,13 @@ def main():
95
99
  meta['wrap-auto'] = True
96
100
  if args.line_breaks:
97
101
  meta['line-breaks'] = True
102
+ if args.macros:
103
+ try:
104
+ meta['macros'] = load_macros([os.path.abspath(f) for f in args.macros])
105
+ registry_with_macros(meta['macros']) # validate before processing files
106
+ except LHTMLError as e:
107
+ print(f'lhtml: error: {e}', file=sys.stderr)
108
+ sys.exit(1)
98
109
 
99
110
  single_file = len(args.inputFiles) == 1
100
111
  single_to_stdout = single_file and args.output is None
@@ -71,6 +71,10 @@ class LHTMLIncludeLoopError(LHTMLError):
71
71
  super().__init__(message)
72
72
 
73
73
 
74
+ class LHTMLMacroError(LHTMLError):
75
+ """Invalid macro definition (name, fields or macros file)."""
76
+
77
+
74
78
  def pos_to_line(text: str, pos: int) -> int:
75
79
  """Convert a character position to a 1-based line number."""
76
80
  if pos < 0 or pos > len(text):
@@ -0,0 +1,276 @@
1
+ """Macros: custom :: tags declared in a configuration (YAML file or dict).
2
+
3
+ A macro is a named shortcut for an HTML element with default classes,
4
+ style and attributes:
5
+
6
+ macros:
7
+ small: {class: small} # small:: ... :: -> <div class="small">
8
+ credit: {tag: span, class: credit}
9
+ gap: {class: gap, empty: true, variant: [s, m, l], default: m}
10
+ demo: {tag: iframe, class: demo, empty: true, url: src}
11
+
12
+ Fields (all optional):
13
+ tag HTML element (default: div)
14
+ class default classes, separated by spaces
15
+ style default inline style, placed before the style of the source
16
+ attrs default HTML attributes, e.g. 'frameborder="0"'
17
+ empty the element has no content: it is closed immediately
18
+ (gap::, demo::url) instead of waiting for a closing ::
19
+ (always the case for HTML void elements such as br, hr)
20
+ url the text after :: is the value of this attribute (src,
21
+ data-src, ...); without it, an img, video or link macro takes
22
+ its URL like img::, video::, link::
23
+ variant the text after :: selects a variant, added as the class
24
+ '<first class>-<variant>' (gap::l -> class="gap gap-l");
25
+ a list restricts the allowed variants
26
+ default variant used when none is given
27
+ doc description, for documentation (ignored)
28
+
29
+ The classes, style and attributes of the source are added to the defaults
30
+ (box::(.good)[margin:0] -> <div class="box good" style="margin:0">).
31
+ A macro is closed by :: or by its name (::box[-]).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import re
38
+ import warnings
39
+
40
+ from .errors import LHTMLMacroError, LHTMLWarning
41
+ from .export_html import _attr, _build_attrs, export_html_generic
42
+ from .pipeline import TagRegistry, tag_registry
43
+
44
+
45
+ NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_-]*$')
46
+ FIELDS = {'tag', 'class', 'style', 'attrs', 'empty', 'url', 'variant', 'default', 'doc'}
47
+ # HTML elements without closing tag (a macro using them is always empty)
48
+ VOID_TAGS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
49
+ 'source', 'track', 'wbr'}
50
+ BUILTIN_TAGS = ('div', 'span', 'link', 'img', 'video', 'videoplay', 'code', 'verbatim',
51
+ 'include', 'nl')
52
+ # LHTML tags whose text is a URL, and the attribute their handler sets with it
53
+ # (these handlers render a complete element: never opened, never empty)
54
+ URL_TAGS = {'img': 'src', 'video': 'src', 'videoplay': 'src', 'link': 'href'}
55
+ RESERVED_NAMES = ('macros',) # a file may hold its macros under 'macros:'
56
+ NOT_ELEMENTS = ('nl', 'code', 'verbatim', 'include') # LHTML tags that are not elements
57
+
58
+
59
+ class OpenTag(str):
60
+ """HTML tag name on the stack of open tags, remembering the macro name
61
+ so that ::name[-] closes it."""
62
+
63
+ def __new__(cls, tag, name):
64
+ obj = super().__new__(cls, tag)
65
+ obj.name = name
66
+ return obj
67
+
68
+
69
+ def _check(name, spec):
70
+ if not isinstance(name, str) or not NAME_RE.match(name):
71
+ raise LHTMLMacroError(f'invalid macro name {name!r}')
72
+ if name in BUILTIN_TAGS:
73
+ raise LHTMLMacroError(f'macro {name!r} would replace a built-in tag')
74
+ if name in RESERVED_NAMES:
75
+ raise LHTMLMacroError(f'{name!r} is not a macro name (reserved)')
76
+ if spec is None:
77
+ spec = {}
78
+ if not isinstance(spec, dict):
79
+ raise LHTMLMacroError(f'macro {name!r}: expected a mapping, got {type(spec).__name__}')
80
+ unknown = set(spec) - FIELDS
81
+ if unknown:
82
+ raise LHTMLMacroError(f'macro {name!r}: unknown field(s) {", ".join(sorted(unknown))}')
83
+ for key in ('tag', 'class', 'style', 'attrs', 'url'):
84
+ if spec.get(key) is not None and not isinstance(spec[key], str):
85
+ raise LHTMLMacroError(f'macro {name!r}: {key} must be a string, got {spec[key]!r}')
86
+ if spec.get('empty') is not None and not isinstance(spec['empty'], bool):
87
+ raise LHTMLMacroError(f'macro {name!r}: empty must be true or false, got {spec["empty"]!r}')
88
+ tag = spec.get('tag') or 'div'
89
+ if not NAME_RE.match(tag):
90
+ raise LHTMLMacroError(f'macro {name!r}: invalid tag {tag!r}')
91
+ if tag in NOT_ELEMENTS:
92
+ raise LHTMLMacroError(f'macro {name!r}: {tag}:: is not an element (tag: {tag})')
93
+ if spec.get('empty') is False and (tag.lower() in VOID_TAGS or tag in URL_TAGS):
94
+ raise LHTMLMacroError(f'macro {name!r}: {tag} has no content (empty cannot be false)')
95
+ if spec.get('url') is not None and not NAME_RE.match(spec['url']):
96
+ raise LHTMLMacroError(f'macro {name!r}: url must be an attribute name, got {spec["url"]!r}')
97
+ variant = spec.get('variant')
98
+ if variant is not None and not isinstance(variant, (bool, list)):
99
+ raise LHTMLMacroError(f'macro {name!r}: variant must be true or a list')
100
+ if isinstance(variant, list) and any(not isinstance(v, (str, int)) or isinstance(v, bool)
101
+ for v in variant):
102
+ raise LHTMLMacroError(f'macro {name!r}: the variants must be names')
103
+ if spec.get('default') is not None and not isinstance(spec['default'], (str, int)):
104
+ raise LHTMLMacroError(f'macro {name!r}: default must be a name')
105
+ if isinstance(variant, list) and spec.get('default') is not None \
106
+ and str(spec['default']) not in [str(v) for v in variant]:
107
+ raise LHTMLMacroError(f'macro {name!r}: default {spec["default"]!r} is not one of the variants')
108
+ if variant and spec.get('url'):
109
+ raise LHTMLMacroError(f'macro {name!r}: variant and url cannot be combined')
110
+ if variant and not str(spec.get('class', '')).split():
111
+ raise LHTMLMacroError(f'macro {name!r}: a variant needs a class')
112
+ return spec
113
+
114
+
115
+ def _join_styles(*styles):
116
+ """'color:red', 'margin:0' -> 'color:red; margin:0'"""
117
+ styles = [s.strip() for s in styles if s and s.strip()]
118
+ return ' '.join(s if s.endswith(';') or k == len(styles) - 1 else s + ';'
119
+ for k, s in enumerate(styles))
120
+
121
+
122
+ def make_handler(name, spec, base=None):
123
+ """Tag handler (see TagRegistry) for one macro definition.
124
+
125
+ The macro adds its defaults (classes, style, attributes) to the parsed
126
+ element, then renders it like any element: with the handler of its tag
127
+ when LHTML has one (img, video, link, ...: `base` registry, the global one
128
+ by default), else as a generic element. There is thus one rendering path
129
+ for an element, whether it is written directly or through a macro."""
130
+ spec = _check(name, spec)
131
+ tag = spec.get('tag') or 'div'
132
+ void = tag.lower() in VOID_TAGS
133
+ classes = (spec.get('class') or '').split()
134
+ style = (spec.get('style') or '').strip()
135
+ attrs = (spec.get('attrs') or '').strip()
136
+ empty = bool(spec.get('empty', False))
137
+ url = spec.get('url')
138
+ variant = spec.get('variant')
139
+ default = spec.get('default')
140
+ delegate = (base if base is not None else tag_registry).get(tag)
141
+ if url and url != URL_TAGS.get(tag):
142
+ delegate = None # the handler of the tag does not set this attribute
143
+ url_delegate = delegate is not None and tag in URL_TAGS # the text is its URL
144
+ if url_delegate:
145
+ url = None
146
+ if tag == 'link' and delegate is not None and style:
147
+ # [] is the text of a link: the style of the macro is an attribute
148
+ attrs = ' '.join(a for a in (attrs, f'style="{_attr(style)}"') if a)
149
+ style = ''
150
+
151
+ def handler(element, tag_to_close, current_directory):
152
+ text = element['text']
153
+ all_classes = list(classes)
154
+ if variant:
155
+ value = text.strip()
156
+ closed = value.endswith('::') # gap::l:: (closed at once)
157
+ if closed:
158
+ value = value[:-2].strip()
159
+ value = value or (str(default) if default is not None else '')
160
+ if value:
161
+ if isinstance(variant, list) and value not in [str(v) for v in variant]:
162
+ warnings.warn(f'{name}::{value}: unknown variant (expected one of '
163
+ f'{", ".join(str(v) for v in variant)}) '
164
+ f'near {element.get("context", "")!r}',
165
+ LHTMLWarning, stacklevel=4)
166
+ all_classes.append(f'{classes[0]}-{value}')
167
+ text = '::' if closed else ''
168
+ inline = ' '.join(a for a in (attrs, element['{}']) if a)
169
+ if url:
170
+ closed = text.rstrip().endswith('::')
171
+ value = text.rstrip()[:-2] if closed else text
172
+ inline = ' '.join(a for a in (f'{url}="{_attr(value.strip())}"', inline) if a)
173
+ text = '::' if closed else ''
174
+ merged = {**element,
175
+ '()': ' '.join(['.' + c for c in all_classes] + ([element['()']] if element['()'] else [])),
176
+ '[]': _join_styles(style, element['[]']),
177
+ '{}': inline,
178
+ 'text': text}
179
+ if void and delegate is None:
180
+ return f'<{tag}{_build_attrs(merged)}>' + ('' if text == '::' else text), True
181
+ if empty and not url_delegate and not merged['text'].endswith('::'):
182
+ merged['text'] += '::' # closed at once
183
+ depth = len(tag_to_close)
184
+ if delegate is not None:
185
+ html, real = delegate(merged, tag_to_close, current_directory)
186
+ else:
187
+ html, real = export_html_generic(merged, tag, tag_to_close), True
188
+ if len(tag_to_close) > depth:
189
+ tag_to_close[-1] = OpenTag(tag_to_close[-1], name)
190
+ return html, real
191
+
192
+ return handler
193
+
194
+
195
+ HTML_TAGS = {'link': 'a', 'videoplay': 'video'} # LHTML tag -> HTML element
196
+
197
+
198
+ def describe_macro(name, spec):
199
+ """What a macro definition gives, for documentation tools: its opening
200
+ HTML, the attribute its text sets (url), whether it has no content
201
+ (empty), its variants (a list, True for any, or None) and default, doc."""
202
+ spec = _check(name, spec)
203
+ tag = spec.get('tag') or 'div'
204
+ html = '<' + HTML_TAGS.get(tag, tag)
205
+ for attribute in ('class', 'style'):
206
+ if spec.get(attribute):
207
+ html += f' {attribute}="{_attr(spec[attribute])}"'
208
+ if spec.get('attrs'):
209
+ html += ' ' + spec['attrs'].strip()
210
+ variant = spec.get('variant')
211
+ return {'html': html + '>',
212
+ 'url': spec.get('url') or URL_TAGS.get(tag),
213
+ 'empty': bool(spec.get('empty')) or tag.lower() in VOID_TAGS or tag in URL_TAGS,
214
+ 'variants': [str(v) for v in variant] if isinstance(variant, list) else (True if variant else None),
215
+ 'default': None if spec.get('default') is None else str(spec['default']),
216
+ 'doc': spec.get('doc')}
217
+
218
+
219
+ def load_macros(source):
220
+ """Macro definitions from a dict, a YAML file name, or a list of them
221
+ (later definitions replace earlier ones, and a definition null removes
222
+ the macro). A dict or file holds the definitions, directly or as its
223
+ only key 'macros'."""
224
+ return {name: spec for name, spec in _load_macros(source).items() if spec is not None}
225
+
226
+
227
+ def _load_macros(source):
228
+ if source is None:
229
+ return {}
230
+ if isinstance(source, (list, tuple)):
231
+ merged = {}
232
+ for item in source:
233
+ merged.update(_load_macros(item))
234
+ return merged
235
+ if isinstance(source, (str, os.PathLike)):
236
+ import yaml
237
+ try:
238
+ with open(source, encoding='utf-8') as f:
239
+ data = yaml.safe_load(f) or {}
240
+ except OSError as e:
241
+ raise LHTMLMacroError(f'cannot read macros file [{source}]: {e}') from e
242
+ except yaml.YAMLError as e:
243
+ raise LHTMLMacroError(f'invalid YAML in macros file [{source}]: {e}') from e
244
+ source = data
245
+ if not isinstance(source, dict):
246
+ raise LHTMLMacroError(f'macros: expected a mapping, got {type(source).__name__}')
247
+ if set(source) == {'macros'}:
248
+ macros = source['macros']
249
+ if macros is None:
250
+ return {}
251
+ if not isinstance(macros, dict):
252
+ raise LHTMLMacroError(f"macros: expected a mapping, got {type(macros).__name__}")
253
+ return dict(macros)
254
+ return dict(source)
255
+
256
+
257
+ def register_macros(macros, registry: TagRegistry | None = None):
258
+ """Register macro definitions (see load_macros) in a tag registry
259
+ (the global one by default). Returns the registry."""
260
+ registry = registry if registry is not None else tag_registry
261
+ # Macros render through the tags known before them (not through each other)
262
+ base = TagRegistry()
263
+ for tag in registry.registered_tags():
264
+ base.register(tag, registry.get(tag))
265
+ for name, spec in load_macros(macros).items():
266
+ registry.register(name, make_handler(name, spec, base))
267
+ return registry
268
+
269
+
270
+ def registry_with_macros(macros, base: TagRegistry | None = None):
271
+ """A new registry: the tags of `base` (global registry by default) plus the macros."""
272
+ base = base if base is not None else tag_registry
273
+ registry = TagRegistry()
274
+ for name in base.registered_tags():
275
+ registry.register(name, base.get(name))
276
+ return register_macros(macros, registry)
@@ -139,6 +139,15 @@ _register_builtin_tags()
139
139
  # Pipeline
140
140
  # ---------------------------------------------------------------------------
141
141
 
142
+ def _macro_sources(value):
143
+ """Macros given as a file name, a mapping or a list of them -> list."""
144
+ if not value:
145
+ return []
146
+ if isinstance(value, (list, tuple)):
147
+ return list(value)
148
+ return [value]
149
+
150
+
142
151
  class ProcessingPipeline:
143
152
  """Orchestrates the LHTML-to-HTML transformation pipeline.
144
153
 
@@ -178,6 +187,16 @@ class ProcessingPipeline:
178
187
  directory_include = [directory_include]
179
188
  ctx.meta['directory_include'] = [str(d) for d in directory_include]
180
189
 
190
+ # Macros (custom tags) declared in the meta, then in the front matter:
191
+ # the front matter adds definitions (and replaces those of same name)
192
+ registry = self.tag_registry
193
+ macros = _macro_sources((meta_arg or {}).get('macros')) + _macro_sources(meta_yaml.get('macros'))
194
+ if macros:
195
+ from .macros import registry_with_macros
196
+ macros = [m if not isinstance(m, str) or os.path.isabs(m)
197
+ else os.path.join(ctx.current_directory, m) for m in macros]
198
+ registry = registry_with_macros(macros, self.tag_registry)
199
+
181
200
  # Phase 2: For each file: verbatim/code blocks and protected zones
182
201
  # (HTML, inline code, URLs, math) are replaced by placeholders,
183
202
  # comments are removed, then includes are expanded (recursive)
@@ -192,11 +211,11 @@ class ProcessingPipeline:
192
211
  ctx.text = process_italic(ctx.text)
193
212
 
194
213
  # Phase 5: Tag elements (uses tag_registry). Inside inline code,
195
- # only named tags (e.g. link::) are processed.
214
+ # only named tags (e.g. link::) are processed, macros excepted.
196
215
  def resolve_attributes(s):
197
216
  return stores.restore(stores.restore(s, 'A'), 'U')
198
217
 
199
- ctx.text = process_tag(ctx.text, ctx.current_directory, self.tag_registry,
218
+ ctx.text = process_tag(ctx.text, ctx.current_directory, registry,
200
219
  resolve=resolve_attributes)
201
220
  ctx.text = stores.restore(ctx.text, 'I', lambda content: render_inline_code(
202
221
  process_tag(content, ctx.current_directory, self.tag_registry, inline=True)))
@@ -425,7 +425,8 @@ def _dispatch_tag(element, tag_to_close, current_directory, registry=None):
425
425
  closing_name = element['text']
426
426
  if not tag_to_close:
427
427
  return _warn_unmatched_closing(element)
428
- if closing_name and tag_to_close[-1] != closing_name:
428
+ if closing_name and closing_name not in (tag_to_close[-1],
429
+ getattr(tag_to_close[-1], 'name', None)):
429
430
  warnings.warn(
430
431
  f'Closing ::{closing_name}[-] but last opened tag is <{tag_to_close[-1]}> '
431
432
  f'(near {element.get("context", "")!r})',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lhtml-markup
3
- Version: 2.4.1
3
+ Version: 2.5.0
4
4
  Summary: Lightweight HTML markup language — simplifies HTML authoring with shorthand syntax
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/drohmer/lhtml
@@ -62,6 +62,7 @@ lhtml input.l.html # Convert to stdout
62
62
  lhtml input.l.html -o output.html # Convert to file
63
63
  lhtml input.l.html -w # Wrap in full HTML document (--wrapAuto)
64
64
  lhtml input.l.html -b # Render source line breaks as <br> (--line-breaks)
65
+ lhtml input.l.html -m macros.yaml # Declare macros, custom :: tags (--macros)
65
66
  lhtml a.l.html b.l.html # Several files: a.html, b.html next to sources
66
67
  lhtml a.l.html b.l.html -o build/ # Several files into a directory
67
68
  python -m lhtml input.l.html # Alternative invocation
@@ -112,6 +113,12 @@ html = lhtml.run(text, {
112
113
  'css': ['style.css'],
113
114
  'js': ['script.js'],
114
115
  })
116
+
117
+ # Macros (custom :: tags, see Macros below)
118
+ html = lhtml.run('box:: x ::\n', {'macros': {'box': {'class': 'box'}}})
119
+ macros = lhtml.load_macros('macros.yaml') # definitions from files or dicts
120
+ registry = lhtml.registry_with_macros(macros) # validates them (LHTMLMacroError)
121
+ info = lhtml.describe_macro('box', macros['box']) # what a definition renders, for docs
115
122
  ```
116
123
 
117
124
 
@@ -414,6 +421,85 @@ include::components/nav.html
414
421
  Included files are recursively processed (up to 20 levels); their own YAML front matter is ignored. An included file looks for its own includes first in its own directory, then in `directory_include`. A circular include raises `LHTMLIncludeLoopError`.
415
422
 
416
423
 
424
+ ### Macros (custom tags from a configuration)
425
+
426
+ A macro is a named `::` tag declared in YAML (or a dict): a shortcut for an
427
+ HTML element with default classes, style and attributes. The styling stays
428
+ in CSS, so a deck or a site changes its look in one place.
429
+
430
+ ```yaml
431
+ # macros.yaml (the definitions may also be at the top level)
432
+ macros:
433
+ small: {class: small}
434
+ credit: {tag: span, class: credit}
435
+ aside: {class: aside}
436
+ box: {class: box}
437
+ gap: {class: gap, empty: true, variant: [s, m, l], default: m}
438
+ demo: {tag: iframe, class: demo, empty: true, url: src, attrs: 'frameborder="0"'}
439
+ ```
440
+
441
+ ```
442
+ aside::[top:400px;]
443
+ img::assets/figure.png[width:450px;]
444
+ ::
445
+
446
+ box::(.good) **Correct** ::
447
+ gap::l
448
+ credit:: Image: Wikimedia Commons ::
449
+ demo::assets/demo/index.html
450
+ ```
451
+
452
+ Output:
453
+ ```html
454
+ <div class="aside" style="top:400px;">
455
+ <img style="width:450px;" src="assets/figure.png" alt="assets/figure.png">
456
+ </div>
457
+
458
+ <div class="box good"> <strong>Correct</strong> </div>
459
+ <div class="gap gap-l"></div>
460
+ <span class="credit"> Image: Wikimedia Commons </span>
461
+ <iframe class="demo" src="assets/demo/index.html" frameborder="0"></iframe>
462
+ ```
463
+
464
+ | Field | Description |
465
+ |-------|-------------|
466
+ | `tag` | HTML element (default `div`) |
467
+ | `class` | Default classes (space separated) |
468
+ | `style` | Default inline style, placed before the style of the source |
469
+ | `attrs` | Default HTML attributes |
470
+ | `empty` | No content: the element is closed at once (`gap::`, `demo::url`) |
471
+ | `url` | The text after `::` is the value of this attribute (`src`, `data-src`, ...). Not needed for `tag: img`, `video`, `videoplay`, `link`: their text is already the URL |
472
+ | `variant` | The text after `::` selects a variant, added as the class `<first class>-<variant>`; a list restricts the allowed values (an unknown one gives a warning) |
473
+ | `default` | Variant used when none is given (one of the variants) |
474
+ | `doc` | Description, ignored by LHTML (for documentation tools; `lhtml.describe_macro(name, spec)` gives what a definition renders) |
475
+
476
+ The classes, style and attributes written in the source are added to the
477
+ defaults, then the element is rendered like any element: a macro whose `tag`
478
+ is an LHTML tag (`img`, `video`, `videoplay`, `link`, ...) uses that tag
479
+ (`photo: {tag: img, class: photo}` gives `photo::a.png` an `alt`, `videoplay`
480
+ keeps its codec variants, the `style` of a `link` macro is an attribute since
481
+ `[]` is the link text); a macro never renders through another macro. A macro is closed by `::` or by its name (`::box[-]`). Macros are
482
+ not active inside inline code (`` `box::` `` stays text), and a macro cannot
483
+ replace a built-in tag (`div`, `img`, ...). Unlike `img::`, the URL of a `url`
484
+ macro is not protected from inline formatting (avoid `__` in it).
485
+
486
+ A variant may be closed at once like any element: `gap::l::`.
487
+
488
+ Declare them with `-m` / `--macros` (repeatable, later files win), with the
489
+ `macros` key of the front matter (file names relative to the page, or
490
+ definitions), or with `lhtml.run(text, {'macros': ...})` (a dict, a file
491
+ name, or a list of them). The macros of the front matter are added to the
492
+ others (a macro of the same name is replaced, `name: null` removes it). A
493
+ file holds the definitions at its top level or under its only key `macros`
494
+ (`macros` is thus not a macro name). An HTML void element
495
+ (`tag: hr`, `br`, ...) is always empty. Invalid definitions (unknown field,
496
+ wrong type) raise `LHTMLMacroError`.
497
+
498
+ ```bash
499
+ lhtml -m macros.yaml slide.l.html
500
+ ```
501
+
502
+
417
503
  ### YAML Front Matter
418
504
 
419
505
  The front matter must be at the very beginning of the file (`---` separators elsewhere are kept as text). Input text is normalized first: a leading BOM is removed and CRLF line endings become LF.
@@ -439,6 +525,7 @@ Supported metadata keys:
439
525
  | `wrap-auto` | boolean | Wrap output in full HTML document |
440
526
  | `line-breaks` | boolean | Render source line breaks as `<br>` |
441
527
  | `directory_include` | list | Directories to search for includes |
528
+ | `macros` | string, list or mapping | Macros: YAML file(s) relative to the page, or definitions (see [Macros](#macros-custom-tags-from-a-configuration)) |
442
529
 
443
530
 
444
531
  ## Using LHTML with Jinja2
@@ -553,6 +640,7 @@ All keys for the `meta` dict passed to `lhtml.run()`:
553
640
  'js': [], # JS files (string or list)
554
641
  'directory_include': [cwd], # Search paths for include:: (default: current directory)
555
642
  'current_directory': '', # Base directory for video codec detection
643
+ 'macros': None, # Macros: dict, YAML file name, or list of them
556
644
  }
557
645
  ```
558
646
 
@@ -571,10 +659,11 @@ All keys for the `meta` dict passed to `lhtml.run()`:
571
659
 
572
660
  ```
573
661
  src/lhtml/
574
- __init__.py # Public API: run(), analyse_tag(), read_yaml()
662
+ __init__.py # Public API: run(), analyse_tag(), read_yaml(), macro functions
575
663
  __main__.py # python -m lhtml
576
664
  cli.py # Command-line interface
577
665
  pipeline.py # ProcessingPipeline, TagRegistry, LexerRegistry
666
+ macros.py # Macros: custom :: tags declared in YAML
578
667
  process.py # Core transformation functions
579
668
  patterns.py # Centralized regex patterns and utilities
580
669
  tag_parser.py # Lark-based parser for :: bracket syntax
@@ -10,6 +10,7 @@ src/lhtml/errors.py
10
10
  src/lhtml/export_html.py
11
11
  src/lhtml/insert_in_text.py
12
12
  src/lhtml/listing.py
13
+ src/lhtml/macros.py
13
14
  src/lhtml/patterns.py
14
15
  src/lhtml/pipeline.py
15
16
  src/lhtml/process.py
@@ -1351,3 +1351,222 @@ def test_cli_version(flag, monkeypatch, capsys):
1351
1351
 
1352
1352
  def test_version_format():
1353
1353
  assert re.fullmatch(r'\d+\.\d+\.\d+', lhtml.__version__)
1354
+
1355
+
1356
+ # ---------------------------------------------------------------------------
1357
+ # Macros (custom :: tags declared in a configuration)
1358
+ # ---------------------------------------------------------------------------
1359
+
1360
+ MACROS = {
1361
+ 'small': {'class': 'small'},
1362
+ 'credit': {'tag': 'span', 'class': 'credit'},
1363
+ 'aside': {'class': 'aside', 'style': 'top:150px;'},
1364
+ 'box': {'class': 'box'},
1365
+ 'gap': {'class': 'gap', 'empty': True, 'variant': ['s', 'm', 'l'], 'default': 'm'},
1366
+ 'demo': {'tag': 'iframe', 'class': 'demo', 'empty': True, 'url': 'src',
1367
+ 'attrs': 'frameborder="0"'},
1368
+ }
1369
+
1370
+
1371
+ def _run_macros(text, macros=MACROS):
1372
+ return lhtml.run(text, {'macros': macros})
1373
+
1374
+
1375
+ class TestMacros:
1376
+
1377
+ def test_container(self):
1378
+ assert _run_macros('box::\nx\n::\n') == '<div class="box">\nx\n</div>\n'
1379
+
1380
+ def test_inline_and_other_tag(self):
1381
+ assert _run_macros('credit:: Milo ::\n') == '<span class="credit"> Milo </span>\n'
1382
+
1383
+ def test_classes_and_style_are_added(self):
1384
+ out = _run_macros('aside::(.wide #f)[top:400px;] x ::\n')
1385
+ assert out == '<div class="aside wide" id="f" style="top:150px; top:400px;"> x </div>\n'
1386
+
1387
+ def test_explicit_closing_by_macro_name(self):
1388
+ with warnings.catch_warnings():
1389
+ warnings.simplefilter('error')
1390
+ out = _run_macros('small::\nbox::\nx\n::box[-]\n::small[-]\n')
1391
+ assert out == '<div class="small">\n<div class="box">\nx\n</div>\n</div>\n'
1392
+
1393
+ def test_explicit_closing_by_html_name(self):
1394
+ with warnings.catch_warnings():
1395
+ warnings.simplefilter('error')
1396
+ assert _run_macros('small::\nx\n::div[-]\n') == '<div class="small">\nx\n</div>\n'
1397
+
1398
+ def test_wrong_explicit_closing_warns(self):
1399
+ with pytest.warns(lhtml.LHTMLWarning, match='last opened tag'):
1400
+ _run_macros('small::\nx\n::box[-]\n')
1401
+
1402
+ def test_empty_with_variant(self):
1403
+ assert _run_macros('gap::\ngap::l\n') == ('<div class="gap gap-m"></div>\n'
1404
+ '<div class="gap gap-l"></div>\n')
1405
+
1406
+ def test_unknown_variant_warns(self):
1407
+ with pytest.warns(lhtml.LHTMLWarning, match='unknown variant'):
1408
+ out = _run_macros('gap::xl\n')
1409
+ assert out == '<div class="gap gap-xl"></div>\n'
1410
+
1411
+ def test_url(self):
1412
+ out = _run_macros('demo::assets/ik/index.html#d2[height:700px;]\n')
1413
+ assert out == ('<iframe class="demo" style="height:700px;" '
1414
+ 'src="assets/ik/index.html#d2" frameborder="0"></iframe>\n')
1415
+
1416
+ def test_not_active_inside_inline_code(self):
1417
+ assert _run_macros('`box:: gap::`\n') == '<code class="code-inline">box:: gap::</code>\n'
1418
+
1419
+ def test_unknown_without_macros(self):
1420
+ assert lhtml.run('gap::\n') == 'gap::\n'
1421
+
1422
+ def test_global_registry_unchanged(self):
1423
+ _run_macros('gap::\n')
1424
+ assert not lhtml.tag_registry.has('gap')
1425
+
1426
+ def test_front_matter_file(self, tmp_path):
1427
+ (tmp_path / 'design.yaml').write_text('macros:\n note: {class: note}\n', encoding='utf-8')
1428
+ out = lhtml.run('---\nmacros: design.yaml\n---\nnote:: hi ::\n',
1429
+ {'current_directory': str(tmp_path) + '/'})
1430
+ assert out.strip() == '<div class="note"> hi </div>'
1431
+
1432
+ def test_later_definition_wins(self):
1433
+ out = lhtml.run('box:: x ::\n', {'macros': [MACROS, {'box': {'class': 'frame'}}]})
1434
+ assert out == '<div class="frame"> x </div>\n'
1435
+
1436
+ @pytest.mark.parametrize('macros', [
1437
+ {'div': {}}, {'nl': {}}, {'2x': {}}, {'box': {'colour': 'red'}},
1438
+ {'box': 'small'}, {'gap': {'variant': True}},
1439
+ {'box': {'class': ['a', 'b']}}, {'box': {'url': True}}, {'box': {'url': 'a b'}},
1440
+ {'box': {'empty': 'false'}}, {'box': {'tag': 'br', 'empty': False}},
1441
+ {'gap': {'class': 'gap', 'variant': [['s']]}}, {'macros': ['box']},
1442
+ ])
1443
+ def test_invalid_definitions(self, macros):
1444
+ with pytest.raises(lhtml.LHTMLMacroError):
1445
+ lhtml.run('x\n', {'macros': macros})
1446
+
1447
+ def test_cli(self, tmp_path, monkeypatch, capsys):
1448
+ from lhtml import cli
1449
+ (tmp_path / 'm.yaml').write_text('box: {class: box}\n', encoding='utf-8')
1450
+ (tmp_path / 'p.l.html').write_text('box:: x ::\n', encoding='utf-8')
1451
+ monkeypatch.setattr('sys.argv', ['lhtml', '-m', str(tmp_path / 'm.yaml'),
1452
+ str(tmp_path / 'p.l.html')])
1453
+ cli.main()
1454
+ assert capsys.readouterr().out == '<div class="box"> x </div>\n'
1455
+
1456
+
1457
+ class TestMacrosRegressions:
1458
+
1459
+ def test_front_matter_adds_to_meta_macros(self):
1460
+ out = lhtml.run('---\nmacros: {note: {class: note}}\n---\nbox:: x ::\nnote:: y ::\n',
1461
+ {'macros': MACROS})
1462
+ assert out.strip() == '<div class="box"> x </div>\n<div class="note"> y </div>'
1463
+
1464
+ def test_front_matter_replaces_same_name(self):
1465
+ out = lhtml.run('---\nmacros: {box: {class: frame}}\n---\nbox:: x ::\n', {'macros': MACROS})
1466
+ assert out.strip() == '<div class="frame"> x </div>'
1467
+
1468
+ def test_void_tag(self):
1469
+ out = lhtml.run('a rule::\nb\n', {'macros': {'rule': {'tag': 'hr', 'class': 'rule'}}})
1470
+ assert out == 'a <hr class="rule">\nb\n'
1471
+
1472
+ def test_macros_key_null(self):
1473
+ assert lhtml.load_macros({'macros': None}) == {}
1474
+
1475
+
1476
+ class TestMacrosDelegation:
1477
+ """A macro renders through the handler of its tag (one rendering path)."""
1478
+
1479
+ def test_img_macro_uses_img_handler(self):
1480
+ out = lhtml.run('photo::a.png[width:10px;]\n',
1481
+ {'macros': {'photo': {'tag': 'img', 'class': 'photo', 'url': 'src'}}})
1482
+ assert out == '<img class="photo" style="width:10px;" src="a.png" alt="a.png">\n'
1483
+
1484
+ def test_videoplay_macro_keeps_video_attributes(self):
1485
+ out = lhtml.run('clip::v.mp4\n', {'macros': {'clip': {'tag': 'videoplay', 'class': 'clip'}}})
1486
+ assert out.startswith('<video autoplay loop muted class="clip">')
1487
+ assert '<source src="v.mp4" type="video/mp4">' in out
1488
+
1489
+ def test_macro_does_not_render_through_another_macro(self):
1490
+ out = lhtml.run('a:: x ::\n', {'macros': {'b': {'class': 'b'}, 'a': {'tag': 'b', 'class': 'a'}}})
1491
+ assert out == '<b class="a"> x </b>\n'
1492
+
1493
+
1494
+ class TestMacrosRound2:
1495
+
1496
+ def test_variant_closed_inline(self):
1497
+ with warnings.catch_warnings():
1498
+ warnings.simplefilter('error')
1499
+ assert _run_macros('gap::l::\n') == '<div class="gap gap-l"></div>\n'
1500
+ out = lhtml.run('box::warn:: x\n', {'macros': {'box': {'class': 'box', 'variant': ['warn']}}})
1501
+ assert out == '<div class="box box-warn"></div> x\n'
1502
+
1503
+ def test_macros_key(self, tmp_path):
1504
+ (tmp_path / 'm.yaml').write_text('macros:\n note: {class: note}\n', encoding='utf-8')
1505
+ assert lhtml.load_macros(str(tmp_path / 'm.yaml')) == {'note': {'class': 'note'}}
1506
+
1507
+ def test_null_removes_macro(self):
1508
+ with pytest.warns(lhtml.LHTMLWarning, match='no matching opening tag'):
1509
+ out = lhtml.run('---\nmacros: {box: null}\n---\nbox:: x ::\n', {'macros': MACROS})
1510
+ assert out.strip() == 'box:: x ::'
1511
+
1512
+ def test_default_must_be_a_variant(self):
1513
+ with pytest.raises(lhtml.LHTMLMacroError):
1514
+ lhtml.run('x\n', {'macros': {'gap': {'class': 'gap', 'variant': ['s'], 'default': 'm'}}})
1515
+
1516
+
1517
+ class TestMacrosRound3:
1518
+
1519
+ def test_url_on_container(self):
1520
+ out = lhtml.run('dx::hello\nx\n::\n', {'macros': {'dx': {'class': 'dx', 'url': 'data-x'}}})
1521
+ assert out == '<div class="dx" data-x="hello">\nx\n</div>\n'
1522
+
1523
+ def test_url_on_span_closed_inline(self):
1524
+ out = lhtml.run('t::Tip:: x\n', {'macros': {'t': {'tag': 'span', 'url': 'title'}}})
1525
+ assert out == '<span title="Tip"></span> x\n'
1526
+
1527
+ def test_url_other_than_the_handler_attribute(self):
1528
+ out = lhtml.run('lazy::a.png\n', {'macros': {'lazy': {'tag': 'img', 'class': 'lazy',
1529
+ 'url': 'data-src'}}})
1530
+ assert out == '<img class="lazy" data-src="a.png">\n'
1531
+
1532
+ @pytest.mark.parametrize('tag, expected', [
1533
+ ('img', '<img class="pic" src="a.png" alt="a.png">\n'),
1534
+ ('link', '<a class="pic" href="a.png"></a>\n'),
1535
+ ])
1536
+ def test_empty_url_tag(self, tag, expected):
1537
+ out = lhtml.run('pic::a.png\n', {'macros': {'pic': {'tag': tag, 'class': 'pic', 'empty': True}}})
1538
+ assert out == expected
1539
+
1540
+ def test_link_style_is_an_attribute(self):
1541
+ macros = {'ext': {'tag': 'link', 'class': 'ext', 'style': 'color:red'}}
1542
+ assert lhtml.run('ext::http://a.org[Doc]\n', {'macros': macros}) == \
1543
+ '<a class="ext" style="color:red" href="http://a.org">Doc</a>\n'
1544
+
1545
+ def test_design_keys_are_not_special(self):
1546
+ with pytest.raises(lhtml.LHTMLMacroError, match="'tokens'"):
1547
+ lhtml.run('x\n', {'macros': {'tokens': {'font': {'small': '85%'}}}})
1548
+
1549
+ def test_macros_is_reserved(self):
1550
+ with pytest.raises(lhtml.LHTMLMacroError, match='reserved'):
1551
+ lhtml.run('x\n', {'macros': {'macros': {'macros': {'class': 'm'}}}})
1552
+
1553
+ def test_css_is_not_a_field(self):
1554
+ with pytest.raises(lhtml.LHTMLMacroError, match='css'):
1555
+ lhtml.run('x\n', {'macros': {'box': {'class': 'box', 'css': '.box {}'}}})
1556
+
1557
+ def test_describe_macro(self):
1558
+ assert lhtml.describe_macro('gap', MACROS['gap']) == {
1559
+ 'html': '<div class="gap">', 'url': None, 'empty': True, 'variants': ['s', 'm', 'l'],
1560
+ 'default': 'm', 'doc': None}
1561
+ info = lhtml.describe_macro('ext', {'tag': 'link', 'class': 'ext'})
1562
+ assert (info['html'], info['url'], info['empty']) == ('<a class="ext">', 'href', True)
1563
+
1564
+ def test_styles_are_separated(self):
1565
+ out = lhtml.run('m::[margin:0] x ::\n', {'macros': {'m': {'style': 'color:red'}}})
1566
+ assert out == '<div style="color:red; margin:0"> x </div>\n'
1567
+
1568
+ @pytest.mark.parametrize('tag', ['nl', 'code', 'verbatim', 'include'])
1569
+ def test_pseudo_tags_are_rejected(self, tag):
1570
+ with pytest.raises(lhtml.LHTMLMacroError, match='not an element'):
1571
+ lhtml.run('x\n', {'macros': {'m': {'tag': tag}}})
1572
+
File without changes
File without changes