Skip to content

Commit d669686

Browse files
authored
✨ NEW: Create ToC from file structure (#14)
This is a re-working of `jupyter-book toc` (by @choldgraf) but with improvements such as: - configurable index name and suffixes - using "natural sort order" for files/folders - using `fnmatch` for matching files/folders to skip - better testing
1 parent 6fdc3af commit d669686

File tree

9 files changed

+492
-27
lines changed

9 files changed

+492
-27
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ exclude: >
55
(?x)^(
66
\.vscode/settings\.json|
77
tests/.*xml|
8+
tests/.*txt|
89
)$
910
1011
repos:

README.md

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,75 @@ meta:
205205
- doc3
206206
```
207207

208+
To build a ToC file from an existing site:
209+
210+
```console
211+
$ sphinx-etoc create-toc path/to/folder
212+
```
213+
214+
Some rules used:
215+
216+
- Files/folders will be skipped if they match a pattern added by `-s` (based on [fnmatch](https://docs.python.org/3/library/fnmatch.html) Unix shell-style wildcards)
217+
- Sub-folders with no content files inside will be skipped
218+
- File and folder names will be sorted by [natural order](https://en.wikipedia.org/wiki/Natural_sort_order)
219+
- If there is a file called `index` (or the name set by `-i`) in any folder, it will be treated as the index file, otherwise the first file by ordering will be used.
220+
221+
The command can also guess a `title` for each file, based on its path:
222+
223+
- The folder name is used for index files, otherwise the file name
224+
- Words are split by `_`
225+
- The first "word" is removed if it is an integer
226+
227+
For example, for a site with files:
228+
229+
```
230+
index.rst
231+
1_a_title.rst
232+
11_another_title.rst
233+
.hidden_file.rst
234+
.hidden_folder/index.rst
235+
1_a_subfolder/index.rst
236+
2_another_subfolder/index.rst
237+
2_another_subfolder/other.rst
238+
3_subfolder/1_no_index.rst
239+
3_subfolder/2_no_index.rst
240+
14_subfolder/index.rst
241+
14_subfolder/subsubfolder/index.rst
242+
14_subfolder/subsubfolder/other.rst
243+
```
244+
245+
will create the ToC:
246+
247+
```console
248+
$ sphinx-etoc create-toc path/to/folder -i index -s ".*" -e ".rst" -t
249+
root: index
250+
sections:
251+
- file: 1_a_title
252+
title: A title
253+
- file: 11_another_title
254+
title: Another title
255+
- file: 1_a_subfolder/index
256+
title: A subfolder
257+
- file: 2_another_subfolder/index
258+
title: Another subfolder
259+
sections:
260+
- file: 2_another_subfolder/other
261+
title: Other
262+
- file: 3_subfolder/1_no_index
263+
title: No index
264+
sections:
265+
- file: 3_subfolder/2_no_index
266+
title: No index
267+
- file: 14_subfolder/index
268+
title: Subfolder
269+
sections:
270+
- file: 14_subfolder/subsubfolder/index
271+
title: Subsubfolder
272+
sections:
273+
- file: 14_subfolder/subsubfolder/other
274+
title: Other
275+
```
276+
208277
## API
209278

210279
The ToC file is parsed to a `SiteMap`, which is a `MutableMapping` subclass, with keys representing docnames mapping to a `DocItem` that stores information on the toctrees it should contain:
@@ -239,20 +308,6 @@ intro:
239308

240309
## Development Notes
241310

242-
Want to have a built-in CLI including commands:
243-
244-
- generate toc from existing documentation toctrees (and remove toctree directives)
245-
- generate toc from existing documentation, but just from its structure (i.e. `jupyter-book toc mybookpath/`)
246-
- generate documentation skeleton from toc
247-
- check toc (without running sphinx)
248-
249-
Process:
250-
251-
- Read toc ("builder-inited" event), error if toc not found
252-
- Note, in jupyter-book: if index page does not exist, works out first page from toc and creates an index page that just redirects to it)
253-
- adds toctree node to page doctree after it is parsed ("doctree-read" event)
254-
- Note, in jupyter-book this was done by physically adding to the text before parsing ("source-read" event), but this is not as robust.
255-
256311
Questions / TODOs:
257312

258313
- Should `titlesonly` default to `True` (as in jupyter-book)?
@@ -264,7 +319,11 @@ Questions / TODOs:
264319
it will add `doc.rst` to the excluded patterns but then, when looking for `doc.md`,
265320
will still select `doc.rst` (since it is first in `source_suffix`).
266321
Maybe open an issue on sphinx, that `doc2path` should respect exclude patterns.
267-
- Intergrate https://github.com/executablebooks/sphinx-multitoc-numbering into this extension? (or upstream PR)
322+
- Integrate https://github.com/executablebooks/sphinx-multitoc-numbering into this extension? (or upstream PR)
323+
- document suppressing warnings
324+
- test against orphan file
325+
- https://github.com/executablebooks/sphinx-book-theme/pull/304
326+
- CLI command to generate toc from existing documentation `toctrees` (and then remove toctree directives)
268327

269328
[github-ci]: https://github.com/executablebooks/sphinx-external-toc/workflows/continuous-integration/badge.svg?branch=main
270329
[github-link]: https://github.com/executablebooks/sphinx-external-toc

sphinx_external_toc/api.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,12 +306,14 @@ def _docitem_to_dict(
306306

307307
def _parse_section(item):
308308
if isinstance(item, FileItem):
309-
return _docitem_to_dict(
310-
site_map[item],
311-
site_map,
312-
skip_defaults=skip_defaults,
313-
parsed_docnames=parsed_docnames,
314-
)
309+
if item in site_map:
310+
return _docitem_to_dict(
311+
site_map[item],
312+
site_map,
313+
skip_defaults=skip_defaults,
314+
parsed_docnames=parsed_docnames,
315+
)
316+
return {FILE_KEY: str(item)}
315317
if isinstance(item, GlobItem):
316318
return {GLOB_KEY: str(item)}
317319
if isinstance(item, UrlItem):

sphinx_external_toc/cli.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from pathlib import PurePosixPath
2+
13
import click
24
import yaml
35

46
from sphinx_external_toc import __version__
5-
from sphinx_external_toc.api import parse_toc_yaml
6-
from sphinx_external_toc.tools import create_site_from_toc
7+
from sphinx_external_toc.api import parse_toc_yaml, create_toc_dict
8+
from sphinx_external_toc.tools import create_site_from_toc, create_site_map_from_path
79

810

911
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@@ -44,3 +46,62 @@ def create_site(toc_file, path, extension, overwrite):
4446
toc_file, root_path=path, default_ext="." + extension, overwrite=overwrite
4547
)
4648
# TODO option to add basic conf.py?
49+
click.secho("SUCCESS!", fg="green")
50+
51+
52+
@main.command("create-toc")
53+
@click.argument(
54+
"site_folder", type=click.Path(exists=True, file_okay=False, dir_okay=True)
55+
)
56+
@click.option(
57+
"-e",
58+
"--extension",
59+
multiple=True,
60+
default=[".rst", ".md"],
61+
show_default=True,
62+
help="File extensions to consider as documents (use multiple times)",
63+
)
64+
@click.option(
65+
"-i",
66+
"--index",
67+
default="index",
68+
show_default=True,
69+
help="File name (without suffix) considered as the index file in a folder",
70+
)
71+
@click.option(
72+
"-s",
73+
"--skip-match",
74+
multiple=True,
75+
default=[".*"],
76+
show_default=True,
77+
help="File/Folder names which match will be ignored (use multiple times)",
78+
)
79+
@click.option(
80+
"-t",
81+
"--guess-titles",
82+
is_flag=True,
83+
help="Guess titles of documents from path names",
84+
)
85+
def create_toc(site_folder, extension, index, skip_match, guess_titles):
86+
"""Create a ToC file from a file structure."""
87+
site_map = create_site_map_from_path(
88+
site_folder,
89+
suffixes=extension,
90+
default_index=index,
91+
ignore_matches=skip_match,
92+
)
93+
if guess_titles:
94+
for docname in site_map:
95+
# don't give a title to the root document
96+
if docname == site_map.root.docname:
97+
continue
98+
filepath = PurePosixPath(docname)
99+
# use the folder name for index files
100+
name = filepath.parent.name if filepath.name == index else filepath.name
101+
# split into words
102+
words = name.split("_")
103+
# remove first word if is an integer
104+
words = words[1:] if words and all(c.isdigit() for c in words[0]) else words
105+
site_map[docname].title = " ".join(words).capitalize()
106+
data = create_toc_dict(site_map)
107+
click.echo(yaml.dump(data, sort_keys=False, default_flow_style=False))

0 commit comments

Comments
 (0)