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.
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/PKG-INFO +91 -2
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/README.md +90 -1
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/__init__.py +6 -4
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/cli.py +11 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/errors.py +4 -0
- lhtml_markup-2.5.0/src/lhtml/macros.py +276 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/pipeline.py +21 -2
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/process.py +2 -1
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/PKG-INFO +91 -2
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/SOURCES.txt +1 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/test/test_lhtml.py +219 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/LICENSE.md +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/pyproject.toml +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/setup.cfg +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/__main__.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/code.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/element_extract.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/export_html.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/insert_in_text.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/listing.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/patterns.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/tag_element.lark +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/tag_parser.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml/wrap_html.py +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/dependency_links.txt +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/entry_points.txt +0 -0
- {lhtml_markup-2.4.1 → lhtml_markup-2.5.0}/src/lhtml_markup.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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,
|
|
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]
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|