Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specifying a section in a snippet #1825

Merged
merged 2 commits into from
Oct 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- **NEW**: Tabbed: Add new syntax to allow forcing a specific tab to be select by default.
- **NEW**: Snippets: Add new option to pass arbitrary HTTP headers.
- **NEW**: Snippets: Allow specifying sections in a snippet and including just the specified section.

## 9.6

Expand Down
38 changes: 38 additions & 0 deletions docs/src/markdown/extensions/snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,44 @@ include.md::3
;--8<--
```

### Snippet Sections

!!! new "New 9.7"

Specifying snippet lines may not always be as the source could change moving, adding, and/or removing lines. A way
around this is to partition a snippet into named sections and then targeting a specific section to be included instead
of specific line numbers.

Snippet sections can be specified by surrounding a block of text with `--8<-- [start:name]` and `--8<-- [end:name]`.
Then a file can simply specify the snippet using the name instead of line numbers.

```
;--8<-- "include.md:name"

;--8<--
include.md:name
;--8<--
```

Unlike other snippet syntax, the section start and end syntax do not have to be on a line by themselves. This allows
you to embed them in comments depending on the file type. When a section is included, the line with the start and end
are always omitted.

If we wanted to include a function from a Python source, we could specify the snippet as follows:

```python
# --8<-- [start:func]
def my_function(var):
pass
# --8<-- [end:func]
```

And then just include it in our document:

```
;--8<-- "example.py:func"
```

### Escaping Snippets Notation

!!! new "New 9.6"
Expand Down
56 changes: 52 additions & 4 deletions pymdownx/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,16 @@ class SnippetPreprocessor(Preprocessor):
'''
)

RE_SNIPPET_FILE = re.compile(r'(.*?)(:[0-9]*)?(:[0-9]*)?$')
RE_SNIPPET_SECTION = re.compile(
r'''(?xi)
^.*?
(?P<inline_marker>-{2,}8<-{2,}[ \t]+)
\[[ \t]*(?P<type>start|end)[ \t]*:[ \t]*(?P<name>[a-z][0-9a-z]*)[ \t]*\]
.*?$
'''
)

RE_SNIPPET_FILE = re.compile(r'(?i)(.*?)(?:(:[0-9]*)?(:[0-9]*)?|(:[a-z][0-9a-z]*)?)$')

def __init__(self, config, md):
"""Initialize."""
Expand All @@ -82,6 +91,37 @@ def __init__(self, config, md):
self.tab_length = md.tab_length
super(SnippetPreprocessor, self).__init__()

def extract_section(self, section, lines):
"""Extract the specified section from the lines."""

new_lines = []
start = False
for l in lines:

# Found a snippet section marker with our specified name
m = self.RE_SNIPPET_SECTION.match(l)
if m is not None and m.group('name') == section:

# We found the start
if not start and m.group('type') == 'start':
start = True
continue

# We found the end
elif start and m.group('type') == 'end':
start = False
break

# We found an end, but no start
else:
return []

# We are currently in a section, so append the line
if start:
new_lines.append(l)

return new_lines

def get_snippet_path(self, path):
"""Get snippet path."""

Expand Down Expand Up @@ -135,7 +175,7 @@ def download(self, url):
return ['']

# Process lines
return [l.decode(self.encoding) for l in response.readlines()]
return [l.decode(self.encoding).rstrip('\r\n') for l in response.readlines()]

def parse_snippets(self, lines, file_name=None, is_url=False):
"""Parse snippets snippet."""
Expand Down Expand Up @@ -201,6 +241,7 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
# Get line numbers (if specified)
end = None
start = None
section = None
m = self.RE_SNIPPET_FILE.match(path)
path = m.group(1).strip()
# Looks like we have an empty file and only lines specified
Expand All @@ -215,6 +256,9 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
starting = m.group(2)
if starting and len(starting) > 1:
start = max(1, int(starting[1:]) - 1)
section_name = m.group(4)
if section_name:
section = section_name[1:]

# Ignore path links if we are in external, downloaded content
is_link = path.lower().startswith(('https://', 'http://'))
Expand All @@ -235,17 +279,21 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
if not url:
# Read file content
with codecs.open(snippet, 'r', encoding=self.encoding) as f:
s_lines = [l for l in f]
s_lines = [l.rstrip('\r\n') for l in f]
if start is not None or end is not None:
s = slice(start, end)
s_lines = s_lines[s]
elif section:
s_lines = self.extract_section(section, s_lines)
else:
# Read URL content
try:
s_lines = self.download(snippet)
if start is not None or end is not None:
s = slice(start, end)
s_lines = s_lines[s]
elif section:
s_lines = self.extract_section(section, s_lines)
except SnippetMissingError:
if self.check_paths:
raise
Expand All @@ -255,7 +303,7 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
new_lines.extend(
[
space + l2 for l2 in self.parse_snippets(
[l.rstrip('\r\n') for l in s_lines],
s_lines,
snippet,
is_url=url
)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_extensions/_snippets/section.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* --8<-- [start: cssSection] */
div {
color: red;
}
/* --8<-- [end: cssSection] */

<!-- --8<-- [start: htmlSection] -->
<div><p>content</p></div>
<!-- --8<-- [end: htmlSection] -->

/* --8<-- [end: cssSection2] */
/* --8<-- [start: cssSection2] */
div {
color: red;
}
/* --8<-- [end: cssSection2] */

<!-- --8<-- [start: htmlSection2] -->
<div><p>content</p></div>
97 changes: 94 additions & 3 deletions tests/test_extensions/test_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class TestSnippets(util.MdCase):
"""Test snippet cases."""

extension = [
'pymdownx.snippets',
'pymdownx.snippets', 'pymdownx.superfences'
]

extension_configs = {
Expand Down Expand Up @@ -223,6 +223,68 @@ def test_start_end_line_block(self):
True
)

def test_section_inline(self):
"""Test section partial in inline snippet."""

self.check_markdown(
R'''
```
--8<-- "section.txt:cssSection"
```
''',
'''
<div class="highlight"><pre><span></span><code>div {
color: red;
}
</code></pre></div>
''',
True
)

def test_section_block(self):
"""Test section partial in inline snippet."""

self.check_markdown(
R'''
--8<--
section.txt:htmlSection
--8<--
''',
'''
<div><p>content</p></div>
''',
True
)

def test_section_end_first(self):
"""Test section when the end is specified first."""

self.check_markdown(
R'''
--8<--
section.txt:cssSection2
--8<--
''',
'''
''',
True
)

def test_section_no_end(self):
"""Test section when the end is not specified."""

self.check_markdown(
R'''
--8<--
section.txt:htmlSection2
--8<--
''',
'''
<div><p>content</p></div>
''',
True
)


class TestSnippetsFile(util.MdCase):
"""Test snippet file case."""
Expand Down Expand Up @@ -502,7 +564,7 @@ def test_url_nested_file(self, mock_urlopen):

@patch('urllib.request.urlopen')
def test_url_lines(self, mock_urlopen):
"""Test nested file in URL."""
"""Test specifying specific lines in a URL."""

content = []
length = 0
Expand All @@ -515,7 +577,7 @@ def test_url_lines(self, mock_urlopen):
cm.status = 200
cm.code = 200
cm.readlines.return_value = content
cm.headers = {'content-length': '183'}
cm.headers = {'content-length': length}
cm.__enter__.return_value = cm
mock_urlopen.return_value = cm

Expand Down Expand Up @@ -615,6 +677,35 @@ def test_content_length_zero(self, mock_urlopen):
True
)

@patch('urllib.request.urlopen')
def test_url_sections(self, mock_urlopen):
"""Test specifying a section in a URL."""

content = []
length = 0
with open('tests/test_extensions/_snippets/section.txt', 'rb') as f:
for l in f:
length += len(l)
content.append(l)

cm = MagicMock()
cm.status = 200
cm.code = 200
cm.readlines.return_value = content
cm.headers = {'content-length': length}
cm.__enter__.return_value = cm
mock_urlopen.return_value = cm

self.check_markdown(
R'''
--8<-- "https://test.com/myfile.md:htmlSection"
''',
'''
<div><p>content</p></div>
''',
True
)


class TestURLSnippetsNoMax(util.MdCase):
"""Test snippet URL cases no max size."""
Expand Down