| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | from __future__ import print_function |
| |
|
| | import argparse |
| | import glob |
| | import os |
| | import re |
| | import sys |
| |
|
| | from scriptCommon import catchPath |
| |
|
| | |
| |
|
| | minTocEntries = 4 |
| |
|
| | headingExcludeDefault = [1,3,4,5] |
| | headingExcludeRelease = [1,3,4,5] |
| |
|
| | documentsDefault = os.path.join(os.path.relpath(catchPath), 'docs/*.md') |
| | releaseNotesName = 'release-notes.md' |
| |
|
| | contentTitle = '**Contents**' |
| | contentLineNo = 4 |
| | contentLineNdx = contentLineNo - 1 |
| |
|
| | |
| |
|
| | VALIDS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-&' |
| |
|
| | def readLines(in_file): |
| | """Returns a list of lines from a input markdown file.""" |
| |
|
| | with open(in_file, 'r') as inf: |
| | in_contents = inf.read().split('\n') |
| | return in_contents |
| |
|
| | def removeLines(lines, remove=('[[back to top]', '<a class="mk-toclify"')): |
| | """Removes existing [back to top] links and <a id> tags.""" |
| |
|
| | if not remove: |
| | return lines[:] |
| |
|
| | out = [] |
| | for l in lines: |
| | if l.startswith(remove): |
| | continue |
| | out.append(l) |
| | return out |
| |
|
| | def removeToC(lines): |
| | """Removes existing table of contents starting at index contentLineNdx.""" |
| | if not lines[contentLineNdx ].startswith(contentTitle): |
| | return lines[:] |
| |
|
| | result_top = lines[:contentLineNdx] |
| |
|
| | pos = contentLineNdx + 1 |
| | while lines[pos].startswith('['): |
| | pos = pos + 1 |
| |
|
| | result_bottom = lines[pos + 1:] |
| |
|
| | return result_top + result_bottom |
| |
|
| | def dashifyHeadline(line): |
| | """ |
| | Takes a header line from a Markdown document and |
| | returns a tuple of the |
| | '#'-stripped version of the head line, |
| | a string version for <a id=''></a> anchor tags, |
| | and the level of the headline as integer. |
| | E.g., |
| | >>> dashifyHeadline('### some header lvl3') |
| | ('Some header lvl3', 'some-header-lvl3', 3) |
| | |
| | """ |
| | stripped_right = line.rstrip('#') |
| | stripped_both = stripped_right.lstrip('#') |
| | level = len(stripped_right) - len(stripped_both) |
| | stripped_wspace = stripped_both.strip() |
| |
|
| | |
| | |
| | |
| | |
| |
|
| | dashified = '' |
| | for c in stripped_wspace: |
| | if c in VALIDS: |
| | dashified += c.lower() |
| | elif c.isspace(): |
| | dashified += '-' |
| | else: |
| | |
| | continue |
| |
|
| | return [stripped_wspace, dashified, level] |
| |
|
| | def tagAndCollect(lines, id_tag=True, back_links=False, exclude_h=None): |
| | """ |
| | Gets headlines from the markdown document and creates anchor tags. |
| | |
| | Keyword arguments: |
| | lines: a list of sublists where every sublist |
| | represents a line from a Markdown document. |
| | id_tag: if true, creates inserts a the <a id> tags (not req. by GitHub) |
| | back_links: if true, adds "back to top" links below each headline |
| | exclude_h: header levels to exclude. E.g., [2, 3] |
| | excludes level 2 and 3 headings. |
| | |
| | Returns a tuple of 2 lists: |
| | 1st list: |
| | A modified version of the input list where |
| | <a id="some-header"></a> anchor tags where inserted |
| | above the header lines (if github is False). |
| | |
| | 2nd list: |
| | A list of 3-value sublists, where the first value |
| | represents the heading, the second value the string |
| | that was inserted assigned to the IDs in the anchor tags, |
| | and the third value is an integer that represents the headline level. |
| | E.g., |
| | [['some header lvl3', 'some-header-lvl3', 3], ...] |
| | |
| | """ |
| | out_contents = [] |
| | headlines = [] |
| | for l in lines: |
| | saw_headline = False |
| |
|
| | orig_len = len(l) |
| | l_stripped = l.lstrip() |
| |
|
| | if l_stripped.startswith(('# ', '## ', '### ', '#### ', '##### ', '###### ')): |
| |
|
| | |
| |
|
| | |
| | if not l.lstrip('#').startswith(' '): |
| | continue |
| | |
| | if len(l) - len(l.lstrip('#')) > 6: |
| | continue |
| | |
| | if orig_len - len(l_stripped) > 3: |
| | continue |
| |
|
| | |
| | if not set(l) - {'#', ' '}: |
| | continue |
| |
|
| | saw_headline = True |
| | dashified = dashifyHeadline(l) |
| |
|
| | if not exclude_h or not dashified[-1] in exclude_h: |
| | if id_tag: |
| | id_tag = '<a class="mk-toclify" id="%s"></a>'\ |
| | % (dashified[1]) |
| | out_contents.append(id_tag) |
| | headlines.append(dashified) |
| |
|
| | out_contents.append(l) |
| | if back_links and saw_headline: |
| | out_contents.append('[[back to top](#table-of-contents)]') |
| | return out_contents, headlines |
| |
|
| | def positioningHeadlines(headlines): |
| | """ |
| | Strips unnecessary whitespaces/tabs if first header is not left-aligned |
| | """ |
| | left_just = False |
| | for row in headlines: |
| | if row[-1] == 1: |
| | left_just = True |
| | break |
| | if not left_just: |
| | for row in headlines: |
| | row[-1] -= 1 |
| | return headlines |
| |
|
| | def createToc(headlines, hyperlink=True, top_link=False, no_toc_header=False): |
| | """ |
| | Creates the table of contents from the headline list |
| | that was returned by the tagAndCollect function. |
| | |
| | Keyword Arguments: |
| | headlines: list of lists |
| | e.g., ['Some header lvl3', 'some-header-lvl3', 3] |
| | hyperlink: Creates hyperlinks in Markdown format if True, |
| | e.g., '- [Some header lvl1](#some-header-lvl1)' |
| | top_link: if True, add a id tag for linking the table |
| | of contents itself (for the back-to-top-links) |
| | no_toc_header: suppresses TOC header if True. |
| | |
| | Returns a list of headlines for a table of contents |
| | in Markdown format, |
| | e.g., [' - [Some header lvl3](#some-header-lvl3)', ...] |
| | |
| | """ |
| | processed = [] |
| | if not no_toc_header: |
| | if top_link: |
| | processed.append('<a class="mk-toclify" id="table-of-contents"></a>\n') |
| | processed.append(contentTitle + '<br>') |
| |
|
| | for line in headlines: |
| | if hyperlink: |
| | item = '[%s](#%s)' % (line[0], line[1]) |
| | else: |
| | item = '%s- %s' % ((line[2]-1)*' ', line[0]) |
| | processed.append(item + '<br>') |
| | processed.append('\n') |
| | return processed |
| |
|
| | def buildMarkdown(toc_headlines, body, spacer=0, placeholder=None): |
| | """ |
| | Returns a string with the Markdown output contents incl. |
| | the table of contents. |
| | |
| | Keyword arguments: |
| | toc_headlines: lines for the table of contents |
| | as created by the createToc function. |
| | body: contents of the Markdown file including |
| | ID-anchor tags as returned by the |
| | tagAndCollect function. |
| | spacer: Adds vertical space after the table |
| | of contents. Height in pixels. |
| | placeholder: If a placeholder string is provided, the placeholder |
| | will be replaced by the TOC instead of inserting the TOC at |
| | the top of the document |
| | |
| | """ |
| | if spacer: |
| | spacer_line = ['\n<div style="height:%spx;"></div>\n' % (spacer)] |
| | toc_markdown = "\n".join(toc_headlines + spacer_line) |
| | else: |
| | toc_markdown = "\n".join(toc_headlines) |
| |
|
| | if placeholder: |
| | body_markdown = "\n".join(body) |
| | markdown = body_markdown.replace(placeholder, toc_markdown) |
| | else: |
| | body_markdown_p1 = "\n".join(body[:contentLineNdx ]) + '\n' |
| | body_markdown_p2 = "\n".join(body[ contentLineNdx:]) |
| | markdown = body_markdown_p1 + toc_markdown + body_markdown_p2 |
| |
|
| | return markdown |
| |
|
| | def outputMarkdown(markdown_cont, output_file): |
| | """ |
| | Writes to an output file if `outfile` is a valid path. |
| | |
| | """ |
| | if output_file: |
| | with open(output_file, 'w') as out: |
| | out.write(markdown_cont) |
| |
|
| | def markdownToclify( |
| | input_file, |
| | output_file=None, |
| | min_toc_len=2, |
| | github=False, |
| | back_to_top=False, |
| | nolink=False, |
| | no_toc_header=False, |
| | spacer=0, |
| | placeholder=None, |
| | exclude_h=None): |
| | """ Function to add table of contents to markdown files. |
| | |
| | Parameters |
| | ----------- |
| | input_file: str |
| | Path to the markdown input file. |
| | |
| | output_file: str (default: None) |
| | Path to the markdown output file. |
| | |
| | min_toc_len: int (default: 2) |
| | Miniumum number of entries to create a table of contents for. |
| | |
| | github: bool (default: False) |
| | Uses GitHub TOC syntax if True. |
| | |
| | back_to_top: bool (default: False) |
| | Inserts back-to-top links below headings if True. |
| | |
| | nolink: bool (default: False) |
| | Creates the table of contents without internal links if True. |
| | |
| | no_toc_header: bool (default: False) |
| | Suppresses the Table of Contents header if True |
| | |
| | spacer: int (default: 0) |
| | Inserts horizontal space (in pixels) after the table of contents. |
| | |
| | placeholder: str (default: None) |
| | Inserts the TOC at the placeholder string instead |
| | of inserting the TOC at the top of the document. |
| | |
| | exclude_h: list (default None) |
| | Excludes header levels, e.g., if [2, 3], ignores header |
| | levels 2 and 3 in the TOC. |
| | |
| | Returns |
| | ----------- |
| | changed: Boolean |
| | True if the file has been updated, False otherwise. |
| | |
| | """ |
| | cleaned_contents = removeLines( |
| | removeToC(readLines(input_file)), |
| | remove=('[[back to top]', '<a class="mk-toclify"')) |
| |
|
| | processed_contents, raw_headlines = tagAndCollect( |
| | cleaned_contents, |
| | id_tag=not github, |
| | back_links=back_to_top, |
| | exclude_h=exclude_h) |
| |
|
| | |
| | if len(raw_headlines) < min_toc_len: |
| | processed_headlines = [] |
| | else: |
| | leftjustified_headlines = positioningHeadlines(raw_headlines) |
| |
|
| | processed_headlines = createToc( |
| | leftjustified_headlines, |
| | hyperlink=not nolink, |
| | top_link=not nolink and not github, |
| | no_toc_header=no_toc_header) |
| |
|
| | if nolink: |
| | processed_contents = cleaned_contents |
| |
|
| | cont = buildMarkdown( |
| | toc_headlines=processed_headlines, |
| | body=processed_contents, |
| | spacer=spacer, |
| | placeholder=placeholder) |
| |
|
| | if output_file: |
| | outputMarkdown(cont, output_file) |
| |
|
| | def isReleaseNotes(f): |
| | return os.path.basename(f) == releaseNotesName |
| |
|
| | def excludeHeadingsFor(f): |
| | return headingExcludeRelease if isReleaseNotes(f) else headingExcludeDefault |
| |
|
| | def updateSingleDocumentToC(input_file, min_toc_len, verbose=False): |
| | """Add or update table of contents in specified file. Return 1 if file changed, 0 otherwise.""" |
| | if verbose : |
| | print( 'file: {}'.format(input_file)) |
| |
|
| | output_file = input_file + '.tmp' |
| |
|
| | markdownToclify( |
| | input_file=input_file, |
| | output_file=output_file, |
| | min_toc_len=min_toc_len, |
| | github=True, |
| | back_to_top=False, |
| | nolink=False, |
| | no_toc_header=False, |
| | spacer=False, |
| | placeholder=False, |
| | exclude_h=excludeHeadingsFor(input_file)) |
| |
|
| | |
| | if sys.version_info >= (3, 3): |
| | os.replace(output_file, input_file) |
| | else: |
| | os.remove(input_file) |
| | os.rename(output_file, input_file) |
| |
|
| | return 1 |
| |
|
| | def updateDocumentToC(paths, min_toc_len, verbose): |
| | """Add or update table of contents to specified paths. Return number of changed files""" |
| | n = 0 |
| | for g in paths: |
| | for f in glob.glob(g): |
| | if os.path.isfile(f): |
| | n = n + updateSingleDocumentToC(input_file=f, min_toc_len=min_toc_len, verbose=verbose) |
| | return n |
| |
|
| | def updateDocumentToCMain(): |
| | """Add or update table of contents to specified paths.""" |
| |
|
| | parser = argparse.ArgumentParser( |
| | description='Add or update table of contents in markdown documents.', |
| | epilog="""""", |
| | formatter_class=argparse.RawTextHelpFormatter) |
| |
|
| | parser.add_argument( |
| | 'Input', |
| | metavar='file', |
| | type=str, |
| | nargs=argparse.REMAINDER, |
| | help='files to process, at default: docs/*.md') |
| |
|
| | parser.add_argument( |
| | '-v', '--verbose', |
| | action='store_true', |
| | help='report the name of the file being processed') |
| |
|
| | parser.add_argument( |
| | '--min-toc-entries', |
| | dest='minTocEntries', |
| | default=minTocEntries, |
| | type=int, |
| | metavar='N', |
| | help='the minimum number of entries to create a table of contents for [{default}]'.format(default=minTocEntries)) |
| |
|
| | parser.add_argument( |
| | '--remove-toc', |
| | action='store_const', |
| | dest='minTocEntries', |
| | const=99, |
| | help='remove all tables of contents') |
| |
|
| | args = parser.parse_args() |
| |
|
| | paths = args.Input if args.Input else [documentsDefault] |
| |
|
| | changedFiles = updateDocumentToC(paths=paths, min_toc_len=args.minTocEntries, verbose=args.verbose) |
| |
|
| | if changedFiles > 0: |
| | print( "Processed table of contents in " + str(changedFiles) + " file(s)" ) |
| | else: |
| | print( "No table of contents added or updated" ) |
| |
|
| | if __name__ == '__main__': |
| | updateDocumentToCMain() |
| |
|
| | |
| |
|