Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ jobs:
- name: Check the committed icons match the mark
run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always

- name: Check a built wheel carries its documentation
run: |
uv build --wheel --out-dir dist
uv run --isolated --no-project --with dist/sampletones-*.whl sampletones self-check --documentation

tests:
name: Tests (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ jobs:
sampletones --version
sampletones --help

- name: Check the wheel carries every resource it needs at startup
- name: Check the wheel carries every resource it needs at startup and its documentation
shell: bash
run: sampletones self-check
run: sampletones self-check --documentation

- name: Check a developer command refuses to run outside a checkout
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ wheels/
/bin/
/build/
/bundles/
/src/sampletones_assets/documentation/

sampletones
!src/sampletones
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help setup install system-deps build release run calibration tracker-playback icons demo pictures clean pre-commit test test-docs benchmarks screens lint format
.PHONY: help setup install system-deps build release run calibration tracker-playback icons docs demo pictures clean pre-commit test test-docs benchmarks screens lint format

ifeq ($(OS),Windows_NT)
PYTHON := python
Expand Down Expand Up @@ -30,6 +30,7 @@ help:
@echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q)
@echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q)
@echo $(Q) make icons - Write the application icon suite from the mark into the package$(Q)
@echo $(Q) make docs - Write the user documentation as HTML pages into the package$(Q)
@echo $(Q) make demo - Make the demo tree (recordings, a library, reconstructions, a project) in DEMO=build/demo$(Q)
@echo $(Q) make pictures - Draw the guide's and the README's pictures of the application from the demo tree (needs Xvfb)$(Q)
@echo $(Q) make clean - Remove build artifacts and cache files$(Q)
Expand Down Expand Up @@ -67,6 +68,9 @@ tracker-playback:
icons:
uv run python -m assets.icons

docs:
uv run python -m assets.documentation

demo:
uv run python -m assets.demo -o $(DEMO)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ recording without the interface, for example. Run `sampletones --help` for the l

## Documentation

The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes.
The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The app opens it in your browser from **Help ▸ Documentation**. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes.

## License

Expand Down
Empty file.
48 changes: 48 additions & 0 deletions assets/documentation/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import sys
from argparse import ArgumentParser
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Optional, Sequence

from assets.documentation.build import SiteRequest, build_site
from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE, SITE_DIRECTORY

PROGRAM: Final[str] = "python -m assets.documentation"
DESCRIPTION: Final[str] = "write the user's documentation as HTML pages in the application's look"
OUTPUT_HELP: Final[str] = "the folder receiving the site; without it, the folder the package ships it from"


@dataclass(frozen=True)
class DocumentationArguments:
"""What a documentation run is given: where the site goes, if anywhere but the package."""

output: Optional[Path]

@classmethod
def parse(cls, argv: Sequence[str]) -> "DocumentationArguments":
"""The arguments ``argv`` states, read by the maker's own parser."""
arguments = cls._parser().parse_args(list(argv))
return cls(output=arguments.output)

@staticmethod
def _parser() -> ArgumentParser:
parser = ArgumentParser(prog=PROGRAM, description=DESCRIPTION)
parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP)
return parser

@property
def directory(self) -> Path:
"""The folder the site is written into: the one given, or the one the package ships it from."""
return self.output if self.output is not None else SITE_DIRECTORY


def main(argv: Sequence[str]) -> int:
"""Writes the documentation site and reports its index."""
given = DocumentationArguments.parse(argv)
site = build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=given.directory))
print(f"Wrote {site.index}")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
172 changes: 172 additions & 0 deletions assets/documentation/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import posixpath
import shutil
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from shutil import copyfile
from string import Template
from typing import Final, Iterable, List, Set

from markdown_it import MarkdownIt
from markdown_it.utils import EnvType

from assets.documentation.links.resolver import EXTERNAL_PREFIXES, BrokenLinkError, LinkResolver
from assets.documentation.markdown.parser import markdown_parser
from assets.documentation.navigation import Navigation
from assets.documentation.pages.model import ParsedPage
from assets.documentation.pages.reader import parse_page
from assets.documentation.pages.sections import image_sources, split_sections
from assets.documentation.paths import STATIC_DIRECTORY
from assets.documentation.project import ProjectFacts
from assets.documentation.published import INDEX_PAGE, page_path, published_pages
from assets.documentation.rendering.cards import section_card
from assets.documentation.rendering.navigation import navigation_html
from assets.documentation.rendering.page import LAYOUT_DIRECTORY, PageChrome, PageContent, render_page
from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
from sampletones_shared.documentation.site import DocumentationSite
from sampletones_tools.web.design import ApplicationDesign
from sampletones_tools.web.writer import write_layout

TEMPLATE_FILE: Final[str] = "page.html"
DOCUMENTATION_STYLESHEET: Final[str] = "documentation.css"
TEXT_NEWLINE: Final[str] = "\n"
PARENT: Final[str] = ".."


class OutputOccupiedError(ValueError):
pass


@dataclass(frozen=True)
class SiteRequest:
"""Where the documentation is read from and where its site is written.

Attributes:
docs: The docs folder of a checkout.
project: The checkout's ``pyproject.toml``, for the version and the online address.
output: The folder the site is written into; empty, or holding an earlier site.
"""

docs: Path
project: Path
output: Path


def build_site(request: SiteRequest) -> DocumentationSite:
"""Write the user's documentation as HTML pages in the application's look.

Every published document becomes a page at the same place in the site, wearing the web
layout in the application's default palette, with the navigation the docs index lays out. The
pictures the pages show are copied beside them, and the site is sealed with its stamp last.

Raises:
OutputOccupiedError: If the output holds files other than an earlier site.
BrokenLinkError: If a page links to a missing document, heading or picture.
"""
_prepare(request.output)
facts = ProjectFacts.read(request.project)
parser = markdown_parser()
pages = {relative: parse_page(parser, request.docs, relative) for relative in published_pages(request.docs)}
navigation = Navigation.read(pages[INDEX_PAGE], pages)
resolver = LinkResolver(
request.docs,
{relative: page.anchors for relative, page in pages.items()},
navigation.folder_pages,
facts.documentation_address,
)
design = ApplicationDesign.load(DEFAULT_PALETTE_NAME)
layout = request.output / LAYOUT_DIRECTORY
stylesheets = write_layout(layout, design)
copyfile(STATIC_DIRECTORY / DOCUMENTATION_STYLESHEET, layout / DOCUMENTATION_STYLESHEET)
chrome = PageChrome(
version=facts.version,
address=facts.documentation_address,
stylesheets=(*stylesheets, DOCUMENTATION_STYLESHEET),
)
template = Template((STATIC_DIRECTORY / TEMPLATE_FILE).read_text(encoding="utf-8"))
pictures: Set[PurePosixPath] = set()
for page in pages.values():
_resolve_links(page, resolver)
html = _page_html(parser, template, chrome, navigation, page, design.glyphs.common.tick)
target = request.output / page_path(page.relative)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html, encoding="utf-8", newline=TEXT_NEWLINE)
pictures.update(_pictures(request.docs, page))

_copy_pictures(request.docs, request.output, pictures)
site = DocumentationSite(request.output)
site.write_stamp()
return site


def _prepare(output: Path) -> None:
site = DocumentationSite(output)
if output.exists() and any(output.iterdir()):
if site.read_stamp() is None:
raise OutputOccupiedError(f"{output} holds files other than a documentation site; give an empty folder")

shutil.rmtree(output)

output.mkdir(parents=True, exist_ok=True)


def _resolve_links(
page: ParsedPage,
resolver: LinkResolver,
) -> None:
for token in page.tokens:
for child in token.children or []:
href = child.attrGet("href") if child.type == "link_open" else None
if href is not None:
child.attrSet("href", resolver.resolve(str(href), page.relative))


def _page_html(
parser: MarkdownIt,
template: Template,
chrome: PageChrome,
navigation: Navigation,
page: ParsedPage,
glyph: str,
) -> str:
env: EnvType = {}

def inline(text: str) -> str:
return str(parser.renderInline(text, env))

sections = split_sections(parser, page.tokens, env)
content = PageContent(
relative=page.relative,
title_html=inline(page.title),
navigation_html=navigation_html(navigation, page.relative, sections, inline),
cards_html="\n".join(section_card(section, glyph) for section in sections),
)
return render_page(template, chrome, content)


def _pictures(
docs: Path,
page: ParsedPage,
) -> List[PurePosixPath]:
pictures: List[PurePosixPath] = []
for source in image_sources(page.tokens):
if source.startswith(EXTERNAL_PREFIXES):
continue

target = posixpath.normpath(posixpath.join(page.relative.parent.as_posix(), source))
if target.startswith(PARENT) or not (docs / target).is_file():
raise BrokenLinkError(f"{page.relative} shows a picture outside the docs or missing: {source}")

pictures.append(PurePosixPath(target))

return pictures


def _copy_pictures(
docs: Path,
output: Path,
pictures: Iterable[PurePosixPath],
) -> None:
for picture in sorted(pictures):
target = output / picture
target.parent.mkdir(parents=True, exist_ok=True)
copyfile(docs / picture, target)
Empty file.
111 changes: 111 additions & 0 deletions assets/documentation/links/resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import posixpath
from pathlib import Path, PurePosixPath
from typing import Dict, Final, FrozenSet, Optional, Tuple
from urllib.parse import urljoin

from assets.documentation.published import MARKDOWN_SUFFIX, is_published, page_path

EXTERNAL_PREFIXES: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:")
FRAGMENT_SEPARATOR: Final[str] = "#"
PARENT: Final[str] = ".."
DIRECTORY_INDEX: Final[str] = "index.md"


class BrokenLinkError(ValueError):
pass


class LinkResolver:
"""Turns a link a document writes into the link its page holds.

A link to another published document becomes a link to its page, and a link to a folder
becomes a link to the folder's index or to the first of its pages the navigation lists. A link
to anything the site leaves out, the development pages among them, becomes a link to the online
copy, so it still leads somewhere. A link to a missing file or heading fails the build.
"""

def __init__(
self,
docs: Path,
anchors: Dict[PurePosixPath, FrozenSet[str]],
folder_pages: Dict[str, PurePosixPath],
address: str,
) -> None:
self._docs = docs
self._anchors = anchors
self._folder_pages = folder_pages
self._address = address.rstrip("/") + "/"

def resolve(
self,
href: str,
page: PurePosixPath,
) -> str:
"""The link ``href``, written in the document ``page``, as the page's link.

Raises:
BrokenLinkError: If the link names a missing file or a missing heading.
"""
if href.startswith(EXTERNAL_PREFIXES):
return href

path, _, fragment = href.partition(FRAGMENT_SEPARATOR)
if not path:
self._check_fragment(page, fragment, page, href)
return href

target = posixpath.normpath(posixpath.join(page.parent.as_posix(), path))
if target == PARENT or target.startswith(f"{PARENT}/") or not is_published(PurePosixPath(target)):
return self._online(target, fragment, page, href)

relative = PurePosixPath(target)
if relative not in self._anchors:
raise BrokenLinkError(f"{page} links to a missing document: {href}")

self._check_fragment(relative, fragment, page, href)
return self._page_link(page, relative, fragment)

def _online(
self,
target: str,
fragment: str,
page: PurePosixPath,
href: str,
) -> str:
source = (self._docs / target).resolve()
if not source.exists():
raise BrokenLinkError(f"{page} links to a missing file: {href}")

folder = self._folder_page(target) if source.is_dir() else None
if folder is not None:
self._check_fragment(folder, fragment, page, href)
return self._page_link(page, folder, fragment)

address = urljoin(self._address, f"{target}/" if source.is_dir() else target)
return f"{address}{FRAGMENT_SEPARATOR}{fragment}" if fragment else address

def _folder_page(self, target: str) -> Optional[PurePosixPath]:
index = PurePosixPath(target) / DIRECTORY_INDEX
if index in self._anchors:
return index

return self._folder_pages.get(target)

def _check_fragment(
self,
target: PurePosixPath,
fragment: str,
page: PurePosixPath,
href: str,
) -> None:
if fragment and target.suffix == MARKDOWN_SUFFIX and fragment not in self._anchors[target]:
raise BrokenLinkError(f"{page} links to a missing heading: {href}")

@staticmethod
def _page_link(
page: PurePosixPath,
target: PurePosixPath,
fragment: str,
) -> str:
link = posixpath.relpath(page_path(target).as_posix(), page.parent.as_posix())
return f"{link}{FRAGMENT_SEPARATOR}{fragment}" if fragment else link
Empty file.
Loading
Loading