simplibs-extractor-html 0.1.0__py3-none-any.whl
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.
- simplibs/extractor_html/__init__.py +82 -0
- simplibs/extractor_html/actions/ApplyToHtmlBlocks.py +29 -0
- simplibs/extractor_html/actions/CleanupHtmlWhitespace.py +22 -0
- simplibs/extractor_html/actions/CompactHtml.py +22 -0
- simplibs/extractor_html/actions/HtmlOutline.py +23 -0
- simplibs/extractor_html/actions/RemoveTemplateImports.py +22 -0
- simplibs/extractor_html/actions/StripHtmlComments.py +23 -0
- simplibs/extractor_html/actions/StripTemplateComments.py +27 -0
- simplibs/extractor_html/actions/TemplateOutline.py +23 -0
- simplibs/extractor_html/actions/__init__.py +57 -0
- simplibs/extractor_html/annotations/TemplateEngine.py +26 -0
- simplibs/extractor_html/annotations/__init__.py +14 -0
- simplibs/extractor_html/constants/HTML_ANY_TAG_PATTERN.py +16 -0
- simplibs/extractor_html/constants/HTML_BLOCK_LANGUAGE_BY_TYPE.py +41 -0
- simplibs/extractor_html/constants/HTML_COMMENT_END_PATTERN.py +19 -0
- simplibs/extractor_html/constants/HTML_CONDITIONAL_PATTERN.py +23 -0
- simplibs/extractor_html/constants/HTML_DEPENDENCY_DIR_NAMES.py +18 -0
- simplibs/extractor_html/constants/HTML_EXTRA_LANGUAGE_BY_SUFFIX.py +31 -0
- simplibs/extractor_html/constants/HTML_HEADING_ELEMENTS.py +15 -0
- simplibs/extractor_html/constants/HTML_ID_ATTRIBUTE_PATTERN.py +21 -0
- simplibs/extractor_html/constants/HTML_IMPLIED_END.py +34 -0
- simplibs/extractor_html/constants/HTML_LICENSE_START.py +14 -0
- simplibs/extractor_html/constants/HTML_LICENSE_TAGS.py +14 -0
- simplibs/extractor_html/constants/HTML_OUTLINE_ELEMENTS.py +32 -0
- simplibs/extractor_html/constants/HTML_OUTLINE_TEXT_LIMIT.py +15 -0
- simplibs/extractor_html/constants/HTML_OUTPUT_DIR_NAMES.py +43 -0
- simplibs/extractor_html/constants/HTML_PLAIN_SUFFIXES.py +18 -0
- simplibs/extractor_html/constants/HTML_RAW_TEXT_END_PATTERNS.py +28 -0
- simplibs/extractor_html/constants/HTML_SUFFIXES.py +22 -0
- simplibs/extractor_html/constants/HTML_TAG_NAME_PATTERN.py +16 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_BLOCKS.py +38 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_IMPORT_TAGS.py +29 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_OUTLINE_TAGS.py +35 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_SUFFIXES.py +19 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_TAG_NAME_PATTERN.py +16 -0
- simplibs/extractor_html/constants/HTML_TEMPLATE_TOKEN_PATTERN.py +16 -0
- simplibs/extractor_html/constants/HTML_TEXT_ELEMENTS.py +16 -0
- simplibs/extractor_html/constants/HTML_TOKEN_PATTERN.py +26 -0
- simplibs/extractor_html/constants/HTML_TYPE_ATTRIBUTE_PATTERN.py +21 -0
- simplibs/extractor_html/constants/HTML_VOID_ELEMENTS.py +21 -0
- simplibs/extractor_html/constants/HTML_WRAPPED_STEM_ENDING.py +14 -0
- simplibs/extractor_html/constants/HTML_WRAPPED_SUFFIXES.py +15 -0
- simplibs/extractor_html/constants/__init__.py +77 -0
- simplibs/extractor_html/file_rules/__init__.py +56 -0
- simplibs/extractor_html/file_rules/atomic/DEPENDENCY_DIRS.py +17 -0
- simplibs/extractor_html/file_rules/atomic/DOT_NAME.py +19 -0
- simplibs/extractor_html/file_rules/atomic/OUTPUT_DIRS.py +17 -0
- simplibs/extractor_html/file_rules/atomic/VCS_DIRS.py +21 -0
- simplibs/extractor_html/file_rules/atomic/WRAPPED_HTML.py +22 -0
- simplibs/extractor_html/file_rules/atomic/__init__.py +26 -0
- simplibs/extractor_html/file_rules/html/HTML_BASE.py +22 -0
- simplibs/extractor_html/file_rules/html/HTML_FILES.py +24 -0
- simplibs/extractor_html/file_rules/html/HTML_PLAIN_BASE.py +24 -0
- simplibs/extractor_html/file_rules/html/HTML_STRICT_EXCLUDE.py +21 -0
- simplibs/extractor_html/file_rules/html/__init__.py +20 -0
- simplibs/extractor_html/logic/__init__.py +44 -0
- simplibs/extractor_html/logic/_helpers/HtmlBlock.py +31 -0
- simplibs/extractor_html/logic/_helpers/HtmlOutlineBuilder.py +189 -0
- simplibs/extractor_html/logic/_helpers/HtmlScan.py +50 -0
- simplibs/extractor_html/logic/_helpers/HtmlTag.py +32 -0
- simplibs/extractor_html/logic/_helpers/Ranges.py +48 -0
- simplibs/extractor_html/logic/_helpers/TemplateScan.py +43 -0
- simplibs/extractor_html/logic/_helpers/TemplateTag.py +33 -0
- simplibs/extractor_html/logic/_helpers/__init__.py +68 -0
- simplibs/extractor_html/logic/_helpers/blank_spans.py +40 -0
- simplibs/extractor_html/logic/_helpers/build_html_outline.py +38 -0
- simplibs/extractor_html/logic/_helpers/build_template_outline.py +75 -0
- simplibs/extractor_html/logic/_helpers/find_blanks_start.py +29 -0
- simplibs/extractor_html/logic/_helpers/find_block_language.py +35 -0
- simplibs/extractor_html/logic/_helpers/find_comments.py +60 -0
- simplibs/extractor_html/logic/_helpers/find_protected_spans.py +48 -0
- simplibs/extractor_html/logic/_helpers/find_template_import_spans.py +37 -0
- simplibs/extractor_html/logic/_helpers/fit_removal_span.py +40 -0
- simplibs/extractor_html/logic/_helpers/is_license_comment.py +26 -0
- simplibs/extractor_html/logic/_helpers/line_of.py +23 -0
- simplibs/extractor_html/logic/_helpers/raise_block_result_invalid.py +38 -0
- simplibs/extractor_html/logic/_helpers/replace_spans.py +42 -0
- simplibs/extractor_html/logic/_helpers/scan_checked.py +45 -0
- simplibs/extractor_html/logic/_helpers/scan_html.py +208 -0
- simplibs/extractor_html/logic/_helpers/scan_templates.py +128 -0
- simplibs/extractor_html/logic/_helpers/scan_templates_checked.py +39 -0
- simplibs/extractor_html/logic/_helpers/skip_html_comment.py +41 -0
- simplibs/extractor_html/logic/_helpers/skip_html_tag.py +46 -0
- simplibs/extractor_html/logic/_helpers/tidy_lines.py +86 -0
- simplibs/extractor_html/logic/_helpers/warn_problems.py +32 -0
- simplibs/extractor_html/logic/apply_to_html_blocks.py +103 -0
- simplibs/extractor_html/logic/cleanup_html_whitespace.py +54 -0
- simplibs/extractor_html/logic/compact_html.py +44 -0
- simplibs/extractor_html/logic/html_comment_spans.py +54 -0
- simplibs/extractor_html/logic/html_outline.py +66 -0
- simplibs/extractor_html/logic/strip_html_comments.py +63 -0
- simplibs/extractor_html/logic/templates/__init__.py +25 -0
- simplibs/extractor_html/logic/templates/remove_template_imports.py +51 -0
- simplibs/extractor_html/logic/templates/strip_template_comments.py +60 -0
- simplibs/extractor_html/logic/templates/template_comment_spans.py +49 -0
- simplibs/extractor_html/logic/templates/template_outline.py +62 -0
- simplibs/extractor_html/plugin/__init__.py +14 -0
- simplibs/extractor_html/plugin/register_all.py +60 -0
- simplibs/extractor_html/presets/__init__.py +43 -0
- simplibs/extractor_html/presets/html_clean_steps.py +54 -0
- simplibs/extractor_html/presets/html_no_imports_steps.py +50 -0
- simplibs/extractor_html/presets/html_outline_steps.py +28 -0
- simplibs/extractor_html/presets/template_structure_steps.py +27 -0
- simplibs/extractor_html/profiles/DJANGO_CLEAN.py +36 -0
- simplibs/extractor_html/profiles/DJANGO_NO_IMPORTS.py +32 -0
- simplibs/extractor_html/profiles/DJANGO_OUTLINE.py +32 -0
- simplibs/extractor_html/profiles/DJANGO_STRUCTURE.py +32 -0
- simplibs/extractor_html/profiles/HTML_CODE_CLEAN.py +32 -0
- simplibs/extractor_html/profiles/HTML_CODE_NO_IMPORTS.py +30 -0
- simplibs/extractor_html/profiles/HTML_DATA_ALL.py +26 -0
- simplibs/extractor_html/profiles/HTML_OUTLINE.py +31 -0
- simplibs/extractor_html/profiles/HTML_PROFILES.py +42 -0
- simplibs/extractor_html/profiles/HTML_SRC_CLEAN.py +31 -0
- simplibs/extractor_html/profiles/JINJA_CLEAN.py +35 -0
- simplibs/extractor_html/profiles/JINJA_NO_IMPORTS.py +32 -0
- simplibs/extractor_html/profiles/JINJA_OUTLINE.py +30 -0
- simplibs/extractor_html/profiles/JINJA_STRUCTURE.py +31 -0
- simplibs/extractor_html/profiles/__init__.py +86 -0
- simplibs/extractor_html/profiles/steps_for_engine.py +39 -0
- simplibs/extractor_html/profiles/steps_for_pages.py +36 -0
- simplibs/extractor_html/py.typed +1 -0
- simplibs_extractor_html-0.1.0.dist-info/METADATA +504 -0
- simplibs_extractor_html-0.1.0.dist-info/RECORD +127 -0
- simplibs_extractor_html-0.1.0.dist-info/WHEEL +5 -0
- simplibs_extractor_html-0.1.0.dist-info/entry_points.txt +2 -0
- simplibs_extractor_html-0.1.0.dist-info/licenses/LICENSE +21 -0
- simplibs_extractor_html-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from simplibs.text import Span
|
|
2
|
+
# Outers
|
|
3
|
+
from ...annotations import TemplateEngine
|
|
4
|
+
from ...constants import (
|
|
5
|
+
HTML_CONDITIONAL_PATTERN,
|
|
6
|
+
HTML_RAW_TEXT_END_PATTERNS,
|
|
7
|
+
HTML_TAG_NAME_PATTERN,
|
|
8
|
+
HTML_TOKEN_PATTERN,
|
|
9
|
+
)
|
|
10
|
+
# Inners
|
|
11
|
+
from .HtmlBlock import HtmlBlock
|
|
12
|
+
from .HtmlScan import HtmlScan
|
|
13
|
+
from .HtmlTag import HtmlTag
|
|
14
|
+
from .find_block_language import find_block_language
|
|
15
|
+
from .fit_removal_span import fit_removal_span
|
|
16
|
+
from .line_of import line_of
|
|
17
|
+
from .scan_templates import scan_templates
|
|
18
|
+
from .skip_html_comment import skip_html_comment
|
|
19
|
+
from .skip_html_tag import skip_html_tag
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scan_html(text: str, *, engine: TemplateEngine | None = None, collect_tags: bool = False) -> HtmlScan:
|
|
23
|
+
"""Read a page once and find its comments, its blocks of code and the places where whitespace matters.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
text: The source.
|
|
27
|
+
engine: The template language of the page; `None` for a page that is only HTML.
|
|
28
|
+
collect_tags: Also record every start tag and end tag (for the outline).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The comments (and which of them are conditional), the template comments, the verbatim
|
|
32
|
+
blocks, the `script` and `style` blocks, the preserved ranges, the tags (if asked) and the
|
|
33
|
+
problems noticed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# 1. A template is read first; the HTML scan then sees the page with the constructs blanked
|
|
37
|
+
templates = scan_templates(text, engine) if engine is not None else None
|
|
38
|
+
source = templates.masked if templates is not None else text
|
|
39
|
+
|
|
40
|
+
# 2. State: where the next stop is looked for, and what has been found
|
|
41
|
+
length = len(source)
|
|
42
|
+
position = 0
|
|
43
|
+
comments: list[Span] = []
|
|
44
|
+
conditional: list[Span] = []
|
|
45
|
+
blocks: list[HtmlBlock] = []
|
|
46
|
+
preserved: list[Span] = []
|
|
47
|
+
tags: list[HtmlTag] = []
|
|
48
|
+
problems: list[str] = []
|
|
49
|
+
pre_start: int | None = None
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
|
|
53
|
+
# 3. Jump to the next place where markup may begin; the text in between is not looked at
|
|
54
|
+
found = HTML_TOKEN_PATTERN.search(source, position)
|
|
55
|
+
if found is None:
|
|
56
|
+
break
|
|
57
|
+
start = found.start()
|
|
58
|
+
token = found.group()
|
|
59
|
+
|
|
60
|
+
# 4. A comment
|
|
61
|
+
if token == "<!--":
|
|
62
|
+
end, closed = skip_html_comment(source, start)
|
|
63
|
+
if not closed:
|
|
64
|
+
problems.append(
|
|
65
|
+
f"a comment on line {line_of(source, start)} is not closed; it runs to the end of the file"
|
|
66
|
+
)
|
|
67
|
+
span = fit_removal_span(text, start, end)
|
|
68
|
+
comments.append(span)
|
|
69
|
+
if HTML_CONDITIONAL_PATTERN.match(source, start):
|
|
70
|
+
conditional.append(span)
|
|
71
|
+
position = end
|
|
72
|
+
|
|
73
|
+
# 5. A CDATA section (in svg and math)
|
|
74
|
+
elif token.upper() == "<![CDATA[":
|
|
75
|
+
close = source.find("]]>", start)
|
|
76
|
+
if close == -1:
|
|
77
|
+
problems.append(f"a CDATA section on line {line_of(source, start)} is not closed")
|
|
78
|
+
position = length
|
|
79
|
+
else:
|
|
80
|
+
position = close + 3
|
|
81
|
+
|
|
82
|
+
# 6. A declaration (`<!DOCTYPE html>`) or a processing instruction (`<?xml ... ?>`)
|
|
83
|
+
elif token == "<?" or token.startswith("<!"):
|
|
84
|
+
close = source.find(">", start)
|
|
85
|
+
if close == -1:
|
|
86
|
+
problems.append(f"a declaration on line {line_of(source, start)} is not closed")
|
|
87
|
+
position = length
|
|
88
|
+
else:
|
|
89
|
+
position = close + 1
|
|
90
|
+
|
|
91
|
+
# 7. An end tag; the end of `pre` closes the range where whitespace matters
|
|
92
|
+
elif token.startswith("</"):
|
|
93
|
+
end, closed = skip_html_tag(source, start)
|
|
94
|
+
if not closed:
|
|
95
|
+
problems.append(f"a tag on line {line_of(source, start)} is not closed")
|
|
96
|
+
name = HTML_TAG_NAME_PATTERN.match(source, start).group(1).lower()
|
|
97
|
+
if collect_tags:
|
|
98
|
+
tags.append(HtmlTag(name, True, (start, end)))
|
|
99
|
+
if name == "pre" and pre_start is not None:
|
|
100
|
+
preserved.append((pre_start, start))
|
|
101
|
+
pre_start = None
|
|
102
|
+
position = end
|
|
103
|
+
|
|
104
|
+
# 8. A start tag
|
|
105
|
+
else:
|
|
106
|
+
end, closed = skip_html_tag(source, start)
|
|
107
|
+
position = end
|
|
108
|
+
if not closed:
|
|
109
|
+
problems.append(f"a tag on line {line_of(source, start)} is not closed")
|
|
110
|
+
continue
|
|
111
|
+
name = HTML_TAG_NAME_PATTERN.match(source, start).group(1).lower()
|
|
112
|
+
if collect_tags:
|
|
113
|
+
tags.append(HtmlTag(name, False, (start, end)))
|
|
114
|
+
|
|
115
|
+
# 9. An element with raw text: the content is skipped as a whole, up to its end tag
|
|
116
|
+
end_pattern = HTML_RAW_TEXT_END_PATTERNS.get(name)
|
|
117
|
+
if end_pattern is not None:
|
|
118
|
+
closing = end_pattern.search(source, end)
|
|
119
|
+
content_end = length if closing is None else closing.start()
|
|
120
|
+
if closing is None:
|
|
121
|
+
problems.append(f"a {name} element on line {line_of(source, start)} is not closed")
|
|
122
|
+
if name in ("script", "style") and source[end:content_end].strip():
|
|
123
|
+
language = find_block_language(name, source[start:end])
|
|
124
|
+
blocks.append(HtmlBlock(name, (end, content_end), language))
|
|
125
|
+
elif name == "textarea":
|
|
126
|
+
preserved.append((end, content_end))
|
|
127
|
+
position = content_end
|
|
128
|
+
|
|
129
|
+
# 10. `pre` opens a range where whitespace matters
|
|
130
|
+
elif name == "pre" and pre_start is None:
|
|
131
|
+
pre_start = end
|
|
132
|
+
|
|
133
|
+
# 11. A `pre` that is never closed keeps its whitespace to the end of the page
|
|
134
|
+
if pre_start is not None:
|
|
135
|
+
preserved.append((pre_start, length))
|
|
136
|
+
|
|
137
|
+
# 12. What the template scan found goes into the result as well
|
|
138
|
+
template_comments = templates.comments if templates is not None else ()
|
|
139
|
+
verbatim = templates.verbatim if templates is not None else ()
|
|
140
|
+
template_problems = templates.problems if templates is not None else ()
|
|
141
|
+
|
|
142
|
+
return HtmlScan(
|
|
143
|
+
comments=tuple(comments),
|
|
144
|
+
conditional=tuple(conditional),
|
|
145
|
+
template_comments=tuple(template_comments),
|
|
146
|
+
verbatim=tuple(verbatim),
|
|
147
|
+
blocks=tuple(blocks),
|
|
148
|
+
preserved=tuple(preserved),
|
|
149
|
+
tags=tuple(tags),
|
|
150
|
+
problems=template_problems + tuple(problems),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_DESIGN_NOTES = """
|
|
155
|
+
# scan_html — A Scanner, Not a Parser
|
|
156
|
+
|
|
157
|
+
## Purpose
|
|
158
|
+
Everything the HTML steps need to know about a page comes from this one pass:
|
|
159
|
+
where the comments are (not the `<!--` inside an attribute or inside a
|
|
160
|
+
script), where the code of another language lies, where whitespace is
|
|
161
|
+
meaningful and, for the outline, which tags there are.
|
|
162
|
+
|
|
163
|
+
## Not a Parser
|
|
164
|
+
It builds no tree. It follows what decides where a comment begins: tags (to
|
|
165
|
+
skip their attributes), the elements with raw text, and the markup that starts
|
|
166
|
+
with `<!` or `<?`. That is why it cannot fail: there is no syntax it refuses.
|
|
167
|
+
The outline builds the little nesting it needs from the recorded tags.
|
|
168
|
+
|
|
169
|
+
## It Jumps
|
|
170
|
+
Most of a page is text and tags. The scanner asks the pattern for the next
|
|
171
|
+
stop and jumps there instead of walking character by character.
|
|
172
|
+
|
|
173
|
+
## Raw Text
|
|
174
|
+
`script` and `style` hold code, `textarea` and `title` hold text; none of them
|
|
175
|
+
holds a comment. Their content is skipped up to the end tag. The content of
|
|
176
|
+
`script` and `style` is reported as a block, with its language, for the step
|
|
177
|
+
that cleans it; the content of `textarea` is reported as preserved. Their
|
|
178
|
+
start and end tags are tags like any other.
|
|
179
|
+
|
|
180
|
+
## Preserved Whitespace
|
|
181
|
+
The content of `pre` and `textarea` is where a changed space changes the page.
|
|
182
|
+
Comments inside `pre` are comments, so the scan goes on in it; only its range
|
|
183
|
+
is recorded.
|
|
184
|
+
|
|
185
|
+
## Tags on Request
|
|
186
|
+
`collect_tags` records every start tag and end tag. A page has a lot of them
|
|
187
|
+
and almost no step needs them, so they are collected only for the outline.
|
|
188
|
+
|
|
189
|
+
## Templates
|
|
190
|
+
With an `engine` the template constructs are read first and blanked, and the
|
|
191
|
+
HTML is read from the blanked text; the offsets are the same in both. Where a
|
|
192
|
+
cut begins and ends is decided on the original text: in the blanked one, the
|
|
193
|
+
blanks that stand for a `{{ x }}` would be taken for blanks that belong to the
|
|
194
|
+
comment, and the comment would cut the construct in front of it as well.
|
|
195
|
+
|
|
196
|
+
## Problems
|
|
197
|
+
A comment, a tag, a CDATA section, a declaration or a raw element that is never
|
|
198
|
+
closed is written down in `problems`, and the steps do not trust a file that
|
|
199
|
+
has any: they leave it as it is and warn (see `scan_checked`).
|
|
200
|
+
|
|
201
|
+
## Known Limits
|
|
202
|
+
* A `<` followed by a letter in text that is not HTML (`a<b` in prose, or in a
|
|
203
|
+
template when no engine is given) is read as a tag.
|
|
204
|
+
* `<script>` is read to the first `</script`, as a browser reads it, even when
|
|
205
|
+
that is inside a string of the script.
|
|
206
|
+
* The legacy raw elements `xmp`, `iframe`, `noembed` and `noframes` are read as
|
|
207
|
+
ordinary elements.
|
|
208
|
+
"""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from simplibs.text import Span
|
|
2
|
+
# Outers
|
|
3
|
+
from ...constants import HTML_TEMPLATE_BLOCKS, HTML_TEMPLATE_TAG_NAME_PATTERN, HTML_TEMPLATE_TOKEN_PATTERN
|
|
4
|
+
# Inners
|
|
5
|
+
from .TemplateScan import TemplateScan
|
|
6
|
+
from .TemplateTag import TemplateTag
|
|
7
|
+
from .blank_spans import blank_spans
|
|
8
|
+
from .fit_removal_span import fit_removal_span
|
|
9
|
+
from .line_of import line_of
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_CLOSERS = {"{#": "#}", "{%": "%}", "{{": "}}"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan_templates(text: str, engine: str) -> TemplateScan:
|
|
16
|
+
"""Read the template constructs of a page once.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
text: The source.
|
|
20
|
+
engine: `"django"` (constructs stay on one line) or `"jinja"` (they may span lines).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The template comments, the verbatim blocks, the tags, the text with every construct blanked,
|
|
24
|
+
and the problems that were noticed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# 1. State: what the engine allows, and what has been found
|
|
28
|
+
single_line = engine == "django"
|
|
29
|
+
blocks = HTML_TEMPLATE_BLOCKS[engine]
|
|
30
|
+
position = 0
|
|
31
|
+
comments: list[Span] = []
|
|
32
|
+
verbatim: list[Span] = []
|
|
33
|
+
tags: list[TemplateTag] = []
|
|
34
|
+
masked_regions: list[Span] = []
|
|
35
|
+
problems: list[str] = []
|
|
36
|
+
|
|
37
|
+
while True:
|
|
38
|
+
|
|
39
|
+
# 2. Jump to the next opening of a construct
|
|
40
|
+
found = HTML_TEMPLATE_TOKEN_PATTERN.search(text, position)
|
|
41
|
+
if found is None:
|
|
42
|
+
break
|
|
43
|
+
start = found.start()
|
|
44
|
+
token = found.group()
|
|
45
|
+
|
|
46
|
+
# 3. A construct that is not closed (in Django: on the same line) is only text
|
|
47
|
+
close = text.find(_CLOSERS[token], start + 2)
|
|
48
|
+
if close == -1 or (single_line and text.find("\n", start, close) != -1):
|
|
49
|
+
position = start + 2
|
|
50
|
+
continue
|
|
51
|
+
end = close + 2
|
|
52
|
+
|
|
53
|
+
# 4. A comment: removable, and hidden from the HTML scan
|
|
54
|
+
if token == "{#":
|
|
55
|
+
comments.append(fit_removal_span(text, start, end))
|
|
56
|
+
|
|
57
|
+
# 5. A tag: a few open a block that runs to an end tag, the others are recorded
|
|
58
|
+
elif token == "{%":
|
|
59
|
+
name_match = HTML_TEMPLATE_TAG_NAME_PATTERN.match(text, start)
|
|
60
|
+
name = name_match.group(1) if name_match else None
|
|
61
|
+
block = blocks.get(name) if name else None
|
|
62
|
+
if block is not None:
|
|
63
|
+
kind, end_pattern = block
|
|
64
|
+
closing = end_pattern.search(text, end)
|
|
65
|
+
if closing is None:
|
|
66
|
+
problems.append(f"a {name} block on line {line_of(text, start)} is not closed")
|
|
67
|
+
else:
|
|
68
|
+
end = closing.end()
|
|
69
|
+
if kind == "comment":
|
|
70
|
+
comments.append(fit_removal_span(text, start, end))
|
|
71
|
+
else:
|
|
72
|
+
verbatim.append((start, end))
|
|
73
|
+
elif name_match is not None:
|
|
74
|
+
arguments = text[name_match.end():close].strip()
|
|
75
|
+
if arguments.endswith(("-", "+")):
|
|
76
|
+
arguments = arguments[:-1].rstrip()
|
|
77
|
+
tags.append(TemplateTag(name, (start, end), arguments))
|
|
78
|
+
|
|
79
|
+
# 6. Everything found is blanked for the HTML scan; reading goes on behind it
|
|
80
|
+
masked_regions.append((start, end))
|
|
81
|
+
position = end
|
|
82
|
+
|
|
83
|
+
return TemplateScan(
|
|
84
|
+
tuple(comments),
|
|
85
|
+
tuple(verbatim),
|
|
86
|
+
tuple(tags),
|
|
87
|
+
blank_spans(text, masked_regions),
|
|
88
|
+
tuple(problems),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_DESIGN_NOTES = """
|
|
93
|
+
# scan_templates — The Curly Braces First
|
|
94
|
+
|
|
95
|
+
## Purpose
|
|
96
|
+
Django and Jinja put `{{ }}`, `{% %}` and `{# #}` anywhere: in text, inside a
|
|
97
|
+
tag, inside a quoted value. They are read first, by their own rules, and the
|
|
98
|
+
page is then read as HTML with them blanked out, so `<a {% if a > b %}x{% endif %}>`
|
|
99
|
+
is a tag and `title="{{ name|default:"x" }}"` is one value.
|
|
100
|
+
|
|
101
|
+
## Django and Jinja
|
|
102
|
+
Django never spans lines with a construct, so a `{#` without its `#}` on the
|
|
103
|
+
same line is only text. Jinja does, and its `{# ... #}` may be long.
|
|
104
|
+
|
|
105
|
+
## Blocks
|
|
106
|
+
`{% comment %}`, `{% verbatim %}` and `{% raw %}` run to their end tag
|
|
107
|
+
(`HTML_TEMPLATE_BLOCKS`). Whatever HTML or template syntax they contain is not
|
|
108
|
+
read: the whole block is blanked, and the tags inside it are not recorded.
|
|
109
|
+
|
|
110
|
+
## Tags
|
|
111
|
+
Every other tag is recorded with its name and arguments, so the steps that
|
|
112
|
+
work with the structure of a template need no scan of their own. A tag inside
|
|
113
|
+
an HTML comment is recorded: the engine renders it, whatever the browser
|
|
114
|
+
makes of the comment.
|
|
115
|
+
|
|
116
|
+
## Only Text When Not Closed
|
|
117
|
+
`{{` without `}}` is plain text. It may be a Vue or Angular expression, a
|
|
118
|
+
piece of documentation or a typing mistake; it is no reason to call the file
|
|
119
|
+
damaged. Only a block that is opened and never closed is.
|
|
120
|
+
|
|
121
|
+
## Known Limits
|
|
122
|
+
* `{{` in a page that is not a template at all (an Angular page, say) is read
|
|
123
|
+
as a variable if `}}` follows on the same line. That changes nothing for the
|
|
124
|
+
HTML scan: the construct is only blanked.
|
|
125
|
+
* Template constructs inside `<script>` and `<style>` are read too, as their
|
|
126
|
+
comments are to be removed there as well.
|
|
127
|
+
* The comment prefixes of Jinja line statements (`# for x in y`) are not read.
|
|
128
|
+
"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Outers
|
|
2
|
+
from ...annotations import TemplateEngine
|
|
3
|
+
# Inners
|
|
4
|
+
from .TemplateScan import TemplateScan
|
|
5
|
+
from .scan_templates import scan_templates
|
|
6
|
+
from .warn_problems import warn_problems
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scan_templates_checked(text: str, *, engine: TemplateEngine) -> TemplateScan | None:
|
|
10
|
+
"""Scan the template constructs of a page and say so when it cannot be trusted.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
text: The source.
|
|
14
|
+
engine: `"django"` or `"jinja"`.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The scan; `None` if the scanner noticed damage (a warning has been given for each problem).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# 1. One pass
|
|
21
|
+
scan = scan_templates(text, engine)
|
|
22
|
+
|
|
23
|
+
# 2. A template with damage is not read any further: the step leaves it as it is
|
|
24
|
+
if scan.problems:
|
|
25
|
+
warn_problems(scan.problems)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
return scan
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DESIGN_NOTES = """
|
|
32
|
+
# scan_templates_checked — Trust the Scan Only if It Found Nothing Wrong
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
The template steps do not need to read the HTML of the page, only its
|
|
36
|
+
constructs, so they use this scan and not `scan_checked`. The rule is the
|
|
37
|
+
same: a template with a block that is never closed is left as it is, with a
|
|
38
|
+
warning, and the extractor calls the file `partial`.
|
|
39
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from ...constants import HTML_COMMENT_END_PATTERN
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def skip_html_comment(text: str, start: int) -> tuple[int, bool]:
|
|
5
|
+
"""Find where a comment ends.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
text: The source.
|
|
9
|
+
start: The offset of the opening `<!--`.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
The offset just after the end of the comment and `True`; for a comment that is never
|
|
13
|
+
closed, the end of the text and `False`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# 1. The two shortest comments end at once: `<!-->` and `<!--->`
|
|
17
|
+
content = start + 4
|
|
18
|
+
if text.startswith(">", content):
|
|
19
|
+
return content + 1, True
|
|
20
|
+
if text.startswith("->", content):
|
|
21
|
+
return content + 2, True
|
|
22
|
+
|
|
23
|
+
# 2. Otherwise the first `-->` (or `--!>`) ends it
|
|
24
|
+
closing = HTML_COMMENT_END_PATTERN.search(text, content)
|
|
25
|
+
if closing is None:
|
|
26
|
+
return len(text), False
|
|
27
|
+
|
|
28
|
+
return closing.end(), True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DESIGN_NOTES = """
|
|
32
|
+
# skip_html_comment — A Comment Ends at `-->`
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
The one rule of an HTML comment, as the specification gives it. Comments do
|
|
36
|
+
not nest, so `<!-- a <!-- b --> c -->` ends at the first `-->`.
|
|
37
|
+
|
|
38
|
+
## An Unclosed Comment
|
|
39
|
+
By the specification, and so for every browser, it runs to the end of the
|
|
40
|
+
file. The scanner reports it: a page cut in half is usually a damaged file.
|
|
41
|
+
"""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
def skip_html_tag(text: str, start: int) -> tuple[int, bool]:
|
|
2
|
+
"""Find where a start tag or an end tag ends.
|
|
3
|
+
|
|
4
|
+
Args:
|
|
5
|
+
text: The source (template constructs already blanked).
|
|
6
|
+
start: The offset of the `<`.
|
|
7
|
+
|
|
8
|
+
Returns:
|
|
9
|
+
The offset just after the closing `>` and `True`; for a tag that is never closed, the end
|
|
10
|
+
of the text and `False`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# 1. Walk to the first `>` that is not inside a quoted value
|
|
14
|
+
length = len(text)
|
|
15
|
+
position = start + 1
|
|
16
|
+
while position < length:
|
|
17
|
+
character = text[position]
|
|
18
|
+
if character == ">":
|
|
19
|
+
return position + 1, True
|
|
20
|
+
if character in "\"'":
|
|
21
|
+
closing = text.find(character, position + 1)
|
|
22
|
+
if closing == -1:
|
|
23
|
+
return length, False
|
|
24
|
+
position = closing + 1
|
|
25
|
+
else:
|
|
26
|
+
position += 1
|
|
27
|
+
|
|
28
|
+
return length, False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DESIGN_NOTES = """
|
|
32
|
+
# skip_html_tag — A Tag Ends at the First `>` Outside Quotes
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
`<a title="a > b" href='x'>` is one tag. A `>` or a `<!--` inside a quoted
|
|
36
|
+
value is a part of the value, so the tag is skipped as a whole and nothing in
|
|
37
|
+
it is taken for a comment.
|
|
38
|
+
|
|
39
|
+
## Quotes
|
|
40
|
+
Any quote opens a value and ends at the same quote, line breaks included, as
|
|
41
|
+
in HTML. A quote that is never closed makes the tag, and the file, damaged.
|
|
42
|
+
|
|
43
|
+
## Template Constructs
|
|
44
|
+
They are blanked before the scan, so `title="{{ x|default:"y" }}"` has no
|
|
45
|
+
quote inside quote left.
|
|
46
|
+
"""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Inners
|
|
2
|
+
from .Ranges import Ranges
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def tidy_lines(text: str, protected: Ranges, *, keep_blank_line: bool) -> str:
|
|
6
|
+
"""Trim the end of every line and reduce the blank lines, except in the lines that touch a protected range.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
text: The source.
|
|
10
|
+
protected: The ranges that must not change.
|
|
11
|
+
keep_blank_line: `True` to keep one blank line where there were several (in the middle of the
|
|
12
|
+
text); `False` to drop every blank line.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The tidied text. A line that touches a protected range is returned exactly as it was.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# 1. Lines without their breaks; a final line break is put back at the end
|
|
19
|
+
has_final_break = text.endswith("\n")
|
|
20
|
+
lines = text.split("\n")
|
|
21
|
+
if has_final_break:
|
|
22
|
+
lines.pop()
|
|
23
|
+
|
|
24
|
+
result: list[str] = []
|
|
25
|
+
pending_blank: str | None = None
|
|
26
|
+
offset = 0
|
|
27
|
+
|
|
28
|
+
for line in lines:
|
|
29
|
+
|
|
30
|
+
# 2. Where this line lies in the text, with its break
|
|
31
|
+
start = offset
|
|
32
|
+
offset += len(line) + 1
|
|
33
|
+
|
|
34
|
+
# 3. A line with a protected part is kept as it is; a blank line owed before it is paid
|
|
35
|
+
if protected.touches(start, offset):
|
|
36
|
+
if pending_blank is not None:
|
|
37
|
+
result.append(pending_blank)
|
|
38
|
+
pending_blank = None
|
|
39
|
+
result.append(line)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# 4. The end of the line is trimmed; a carriage return of a Windows line break stays
|
|
43
|
+
carriage = "\r" if line.endswith("\r") else ""
|
|
44
|
+
body = (line[:-1] if carriage else line).rstrip(" \t")
|
|
45
|
+
|
|
46
|
+
# 5. A blank line is dropped, or reduced to one that is written only if text follows
|
|
47
|
+
if not body.strip():
|
|
48
|
+
if keep_blank_line and result and pending_blank is None:
|
|
49
|
+
pending_blank = carriage
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# 6. A line with text: the blank line it was owed first
|
|
53
|
+
if pending_blank is not None:
|
|
54
|
+
result.append(pending_blank)
|
|
55
|
+
pending_blank = None
|
|
56
|
+
result.append(body + carriage)
|
|
57
|
+
|
|
58
|
+
# 7. Blank lines at the end are not written (the one still owed is dropped)
|
|
59
|
+
tidied = "\n".join(result)
|
|
60
|
+
return tidied + "\n" if has_final_break and result else tidied
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_DESIGN_NOTES = """
|
|
64
|
+
# tidy_lines — The Lines of a Page, Tidied
|
|
65
|
+
|
|
66
|
+
## Purpose
|
|
67
|
+
The shared work of the two whitespace steps: the blanks at the end of each
|
|
68
|
+
line go, and the blank lines go or are reduced to one.
|
|
69
|
+
|
|
70
|
+
## The Protected Lines
|
|
71
|
+
A line that has any part in a protected range is returned untouched, and a
|
|
72
|
+
blank line owed in front of it is paid first, so the spacing around a `<pre>`
|
|
73
|
+
is kept as it was reduced to.
|
|
74
|
+
|
|
75
|
+
## The Edges
|
|
76
|
+
There is no blank line at the start or at the end of the result, and a text
|
|
77
|
+
that ended with a line break still does. A text of blank lines only is empty.
|
|
78
|
+
|
|
79
|
+
## Windows Line Breaks
|
|
80
|
+
The carriage return in front of a line break is kept: the step trims blanks,
|
|
81
|
+
it does not change the kind of line break of the file.
|
|
82
|
+
|
|
83
|
+
## Known Limits
|
|
84
|
+
A quoted attribute value or a template tag that spans lines is tidied as any
|
|
85
|
+
other text: trailing blanks inside it go.
|
|
86
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def warn_problems(problems: Iterable[str]) -> None:
|
|
6
|
+
"""Turn sentences about a damaged file into warnings.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
problems: What looked wrong, one sentence each.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# 1. One warning for each, in the order of the file
|
|
13
|
+
for problem in problems:
|
|
14
|
+
warnings.warn(problem, UserWarning, stacklevel=2)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DESIGN_NOTES = """
|
|
18
|
+
# warn_problems — A File That Is Not Quite Right Is Not an Error
|
|
19
|
+
|
|
20
|
+
## Purpose
|
|
21
|
+
A step is a plain function of a text and knows nothing about the extractor.
|
|
22
|
+
`warnings.warn` is the one channel it can use without importing anything. The
|
|
23
|
+
extractor collects the warnings of a file, calls the file `partial` and writes
|
|
24
|
+
the sentence into the report, while the file stays in the output.
|
|
25
|
+
|
|
26
|
+
## Why `UserWarning`
|
|
27
|
+
It is the category the extractor listens to.
|
|
28
|
+
|
|
29
|
+
## Outside the Extractor
|
|
30
|
+
Called on its own, the step gives the usual Python warning on the error
|
|
31
|
+
stream, once for each place.
|
|
32
|
+
"""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Outers
|
|
4
|
+
from ..annotations import TemplateEngine
|
|
5
|
+
# Inners
|
|
6
|
+
from ._helpers import raise_block_result_invalid, replace_spans, scan_checked
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def apply_to_html_blocks(
|
|
11
|
+
text: str,
|
|
12
|
+
*,
|
|
13
|
+
script: Callable[[str], str] | None = None,
|
|
14
|
+
style: Callable[[str], str] | None = None,
|
|
15
|
+
engine: TemplateEngine | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Run a step on the code inside the `script` and `style` elements of a page.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
text: The source.
|
|
21
|
+
script: The step for the content of each `<script>` that holds JavaScript (no `type`, `module`,
|
|
22
|
+
`text/javascript`...). `None` leaves them as they are.
|
|
23
|
+
style: The step for the content of each `<style>` that holds CSS. `None` leaves them as they are.
|
|
24
|
+
engine: The template language of the page (`"django"` or `"jinja"`); `None` for a page that is only HTML.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The page with the content of those elements replaced by what the steps returned. A `script` that holds
|
|
28
|
+
JSON or a template of a framework is not touched. A page that looks damaged (a comment, a tag or a
|
|
29
|
+
`script` that is never closed) comes back unchanged, with a warning.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ParamError: A step returned something that is not a text.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
apply_to_html_blocks("<style>a{}</style>", style=str.upper)
|
|
36
|
+
# '<style>A{}</style>'
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# 1. Read the page; a damaged one is left as it is
|
|
40
|
+
scan = scan_checked(text, engine=engine)
|
|
41
|
+
if scan is None:
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
# 2. Each block of code gets the step of its kind
|
|
45
|
+
steps = {"script": script, "style": style}
|
|
46
|
+
replacements = []
|
|
47
|
+
for block in scan.blocks:
|
|
48
|
+
step = steps[block.kind]
|
|
49
|
+
if step is None or block.language is None:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
start, end = block.span
|
|
53
|
+
content = text[start:end]
|
|
54
|
+
result = step(content)
|
|
55
|
+
if not isinstance(result, str):
|
|
56
|
+
raise_block_result_invalid(block.kind, result)
|
|
57
|
+
if result != content:
|
|
58
|
+
replacements.append((block.span, result))
|
|
59
|
+
|
|
60
|
+
# 3. Put the new content where the old one was
|
|
61
|
+
return replace_spans(text, replacements)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_DESIGN_NOTES = """
|
|
65
|
+
# apply_to_html_blocks — Another Language Inside a Page
|
|
66
|
+
|
|
67
|
+
## Purpose
|
|
68
|
+
A page holds JavaScript in `<script>` and CSS in `<style>`. The steps that
|
|
69
|
+
clean them exist (the JavaScript and CSS add-ons); this one gives them the
|
|
70
|
+
right piece of the page and puts the result back. It knows nothing about what
|
|
71
|
+
the steps do: they are parameters, so the steps of other add-ons are used
|
|
72
|
+
without this library depending on them.
|
|
73
|
+
|
|
74
|
+
## Steps Are Functions of a Text
|
|
75
|
+
Any `str -> str` is a step: a function, a lambda, an action, a whole pipeline
|
|
76
|
+
(`StripJsComments() >> RemoveJsImports()`).
|
|
77
|
+
|
|
78
|
+
## Only Code
|
|
79
|
+
A block whose `type` says it is not code (`application/json`, `importmap`, a
|
|
80
|
+
template of a framework) is data, and the step never sees it. A block with
|
|
81
|
+
nothing in it (`<script src="a.js"></script>`) is not offered either.
|
|
82
|
+
|
|
83
|
+
## The Content Only
|
|
84
|
+
A step gets what stands between `>` and `</script`, and nothing more, so a
|
|
85
|
+
line comment at the end of an inline script cannot reach the end tag.
|
|
86
|
+
|
|
87
|
+
## Templates
|
|
88
|
+
With an `engine` the page is read with the template constructs blanked, so
|
|
89
|
+
`{{ x }}` or a `{% if %}` inside a start tag does not hide the block. The
|
|
90
|
+
step is given the original content, template tags and all.
|
|
91
|
+
|
|
92
|
+
## What It Does Not Touch
|
|
93
|
+
The attributes that hold code (`style="..."`, `onclick="..."`) are not blocks
|
|
94
|
+
and stay as they are.
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
A step that raises is not caught: the exception reaches the caller as it was.
|
|
98
|
+
A step that returns something other than a text is reported by name.
|
|
99
|
+
|
|
100
|
+
## A Damaged Page
|
|
101
|
+
It is returned as it is, with a warning, so the extractor calls the file
|
|
102
|
+
`partial`.
|
|
103
|
+
"""
|