Skip to content

Commit

Permalink
Added navbar UI
Browse files Browse the repository at this point in the history
  • Loading branch information
josephlewis42 committed Oct 23, 2024
1 parent a43a92c commit 94378e6
Show file tree
Hide file tree
Showing 31 changed files with 3,306 additions and 133 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Page navigation is now dynamically rendered reducing file sizes. (#24, #31)

## [0.1.0] - 2024-09-27

Expand Down
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
FROM node:20-alpine as zimui

WORKDIR /src
COPY zimui /src
RUN yarn install --frozen-lockfile
RUN yarn build

FROM python:3.12-slim-bookworm
LABEL org.opencontainers.image.source https://github.com/openzim/devdocs

Expand Down Expand Up @@ -25,4 +32,9 @@ COPY *.md /src/
RUN pip install --no-cache-dir /src \
&& rm -rf /src

# Copy zimui build output
COPY --from=zimui /src/dist /src/zimui

ENV DEVDOCS_ZIMUI_DIST=/src/zimui

CMD ["devdocs2zim", "--help"]
95 changes: 64 additions & 31 deletions src/devdocs2zim/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from collections import defaultdict
from collections.abc import Callable
from enum import Enum
from functools import cached_property

import requests
from pydantic import BaseModel, TypeAdapter, computed_field
from pydantic import BaseModel, ConfigDict, TypeAdapter
from pydantic.alias_generators import to_camel

from devdocs2zim.constants import logger
from devdocs2zim.constants import DEVDOCS_LANDING_PAGE, LICENSE_FILE, logger

HTTP_TIMEOUT_SECONDS = 15

Expand Down Expand Up @@ -140,34 +140,46 @@ def sort_precedence(self) -> SortPrecedence:
return SortPrecedence.CONTENT


class NavigationSection(BaseModel):
"""Represents a single section of a devdocs navigation tree."""
class NavbarPageEntry(BaseModel):
"""Model of the a page in the navbar."""

# Heading information for the group of links.
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

# Display name of the page e.g. "Introduction"
name: str
# Links to display in the section.
links: list[DevdocsIndexEntry]

@computed_field
@property
def count(self) -> int:
"""Number of links in the section."""
return len(self.links)
# Link to the page
href: str


class NavbarSectionEntry(BaseModel):
"""Model of the a section in the navbar."""

model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

# Display name of the section e.g. "Tutorials"
name: str

# Pages in the section
children: list[NavbarPageEntry]

@cached_property
def _contained_pages(self) -> set[str]:
return {link.path_without_fragment for link in self.links}

def opens_for_page(self, page_path: str) -> bool:
"""Returns whether this section should be rendered open for the given page."""
class NavbarDocument(BaseModel):
"""Model of the document to populate each page's navbar with."""

# Some docs like Lua or CoffeeScript have all of their content in the index.
# Others like RequireJS are split between index and additional pages.
# We don't want sections opening when the user navigates to the index.
if page_path == "index":
return False
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

return page_path in self._contained_pages
# Display name of the document e.g. "Lua"
name: str
# Link to the main root of the document. DevDocs makes this "index"
# implicitly.
landing_href: str = DEVDOCS_LANDING_PAGE
# Link to the main root of the document, usually "index"
license_href: str = LICENSE_FILE
# Version information to display.
version: str
# Sections to show
children: list[NavbarSectionEntry]


class DevdocsIndex(BaseModel):
Expand All @@ -180,33 +192,54 @@ class DevdocsIndex(BaseModel):
# These are displayed in the order they're found grouped by sort_precedence.
types: list[DevdocsIndexType]

def build_navigation(self) -> list[NavigationSection]:
"""Builds a navigation hierarchy that's soreted correctly for rendering."""
def build_navigation(self) -> list[NavbarSectionEntry]:
"""Builds a navigation hierarchy that's sorted correctly for rendering."""

sections_by_precedence: dict[SortPrecedence, list[DevdocsIndexType]] = (
defaultdict(list)
)
for section in self.types:
sections_by_precedence[section.sort_precedence()].append(section)

links_by_section_name: dict[str, list[DevdocsIndexEntry]] = defaultdict(list)
links_by_section_name: dict[str, list[NavbarPageEntry]] = defaultdict(list)
for entry in self.entries:
if entry.type is None:
continue
links_by_section_name[entry.type].append(entry)
links_by_section_name[entry.type].append(
NavbarPageEntry(
name=entry.name,
href=entry.path,
)
)

output: list[NavigationSection] = []
output: list[NavbarSectionEntry] = []
for precedence in SortPrecedence:
for section in sections_by_precedence[precedence]:
output.append(
NavigationSection(
NavbarSectionEntry(
name=section.name,
links=links_by_section_name[section.name],
children=links_by_section_name[section.name],
)
)

return output

def build_navbar_json(self, name: str, version: str) -> str:
"""Creates a navbar entry with the given name and version.
Parameters
name: Name of the root element in the navbar.
version: Version of the root element in the navbar.
"""

document = NavbarDocument(
name=name,
version=version,
children=self.build_navigation(),
)

return document.model_dump_json(by_alias=True) # type: ignore


class DevdocsClient:
"""Utility functions to read data from devdocs."""
Expand Down
6 changes: 6 additions & 0 deletions src/devdocs2zim/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,10 @@
# As of 2024-07-28 all documentation appears to be in English.
LANGUAGE_ISO_639_3 = "eng"

# File name for the licenses.txt template and output.
LICENSE_FILE = "licenses.txt"

# Implicit key of the landing page for each DevDocs document
DEVDOCS_LANDING_PAGE = "index"

logger = getLogger(NAME, level=logging.DEBUG)
11 changes: 11 additions & 0 deletions src/devdocs2zim/entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import logging
import os

from devdocs2zim.client import DevdocsClient
from devdocs2zim.constants import (
Expand Down Expand Up @@ -71,6 +72,15 @@ def main() -> None:
default=DEVDOCS_DOCUMENTS_URL,
)

parser.add_argument(

Check warning on line 75 in src/devdocs2zim/entrypoint.py

View check run for this annotation

Codecov / codecov/patch

src/devdocs2zim/entrypoint.py#L75

Added line #L75 was not covered by tests
"--zimui-dist",
type=str,
help=(
"Directory containing Vite build output from the Zim UI Vue.JS application"
),
default=os.getenv("DEVDOCS_ZIMUI_DIST", "zimui/dist"),
)

args = parser.parse_args()

logger.setLevel(level=logging.DEBUG if args.debug else logging.INFO)
Expand All @@ -88,6 +98,7 @@ def main() -> None:
zim_config=zim_config,
output_folder=args.output_folder,
doc_filter=doc_filter,
zimui_dist=args.zimui_dist,
).run()
except Exception as e:
logger.exception(e)
Expand Down
52 changes: 49 additions & 3 deletions src/devdocs2zim/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@
DevdocsIndexEntry,
DevdocsMetadata,
)
from devdocs2zim.constants import LANGUAGE_ISO_639_3, NAME, ROOT_DIR, VERSION, logger
from devdocs2zim.constants import (
LANGUAGE_ISO_639_3,
LICENSE_FILE,
NAME,
ROOT_DIR,
VERSION,
logger,
)

# Content to display for pages missing from DevDocs.
MISSING_PAGE = (
Expand Down Expand Up @@ -291,6 +298,7 @@ def __init__(
zim_config: ZimConfig,
doc_filter: DocFilter,
output_folder: str,
zimui_dist: str,
) -> None:
"""Initializes Generator.
Expand All @@ -299,11 +307,13 @@ def __init__(
zim_config: Configuration for ZIM metadata.
doc_filter: User supplied filter selecting with docs to convert.
output_folder: Directory to write ZIMs into.
zimui_dist: Directory containing the zimui code.
"""
self.devdocs_client = devdocs_client
self.zim_config = zim_config
self.doc_filter = doc_filter
self.output_folder = output_folder
self.zimui_dist = Path(zimui_dist)

os.makedirs(self.output_folder, exist_ok=True)

Expand All @@ -314,7 +324,7 @@ def __init__(
)

self.page_template = self.env.get_template("page.html") # type: ignore
self.licenses_template = self.env.get_template("licenses.txt") # type: ignore
self.licenses_template = self.env.get_template(LICENSE_FILE) # type: ignore

self.logo_path = self.asset_path("devdocs_48.png")
self.copyright_path = self.asset_path("COPYRIGHT")
Expand Down Expand Up @@ -352,7 +362,7 @@ def load_common_files(self) -> list[StaticItem]:
StaticItem(
# Documentation doesn't have file extensions so this
# file won't conflict with the dynamic content.
path="licenses.txt",
path=LICENSE_FILE,
content=self.licenses_template.render( # type: ignore
copyright=self.copyright_path.read_text(),
license=self.license_path.read_text(),
Expand All @@ -363,6 +373,29 @@ def load_common_files(self) -> list[StaticItem]:
)
)

# Dynamic navbar `assets/index.js` MUST exist because it's referenced by
# the page.html template. Other files are added so they can be dynamically
# loaded by index.js if needed.
if not Path(self.zimui_dist, "assets", "index.js").exists():
raise ValueError(

Check warning on line 380 in src/devdocs2zim/generator.py

View check run for this annotation

Codecov / codecov/patch

src/devdocs2zim/generator.py#L380

Added line #L380 was not covered by tests
f"Missing assets/index.js in {self.zimui_dist}. You might need to "
"build `zimui` or set --zimui-dist"
)

for file in Path(self.zimui_dist, "assets").rglob("*"):
if file.is_dir():
continue

Check warning on line 387 in src/devdocs2zim/generator.py

View check run for this annotation

Codecov / codecov/patch

src/devdocs2zim/generator.py#L387

Added line #L387 was not covered by tests
path = Path(file).relative_to(self.zimui_dist).as_posix()

static_files.append(
StaticItem(
path=path,
filepath=file,
is_front=False,
auto_index=False,
)
)

return static_files

def run(self) -> list[Path]:
Expand Down Expand Up @@ -491,6 +524,19 @@ def add_zim_contents(
# but isn't in the dynamic list of pages.
page_to_title["index"] = f"{doc_metadata.name} Documentation"

nav_json = index.build_navbar_json(
name=doc_metadata.name, version=doc_metadata.version
)
creator.add_item( # type: ignore
StaticItem(
path="navbar.json",
content=nav_json,
is_front=False,
mimetype="application/json",
auto_index=False,
)
)

nav_sections = index.build_navigation()

logger.info(f" Rendering {len(page_to_title)} pages...")
Expand Down
43 changes: 6 additions & 37 deletions src/devdocs2zim/templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,53 +10,22 @@
<head>
<meta charset="utf-8">
<title>{{title}}</title>
{# Prefetch navbar to limit flash. #}
<link rel="preload" href="{{rel_prefix | safe}}assets/index.js" as="script" type="text/javascript" />

<link rel="stylesheet" type="text/css" href="{{rel_prefix | safe}}application.css" />
<style type="text/css">
/* Make the <details> tag based navigation match the look and feel of devdocs.io. */
._list-count, ._list-enable {
margin-right: .375rem;
}
._list-item {
padding: .25rem;
}
</style>
<link rel="stylesheet" type="text/css" href="{{rel_prefix | safe}}assets/index.css" />
</head>
<body>
<div class="_app">
{# Remove top padding which is usually reserved for the search bar. #}
<section class="_sidebar" style="padding-top: 0px">
<div class="_list">
<a href="{{rel_prefix | safe}}index" class="_list-item">{{devdocs_metadata.name}}</a>
<div class="_list">
{% for section in nav_sections %}
<details {% if section.opens_for_page(path) %}open{% endif %}>
<summary>
<span class="_list-item" style="display:inline; box-shadow: none;">
<span class="_list-count">{{ section.count | safe}}</span>
<span>{{ section.name }}</span>
</span>
</summary>
<div class="_list">
{% for link in section.links %}
<a
href="{{rel_prefix | safe}}{{link.path | safe}}"
class="_list-item _list-hover {% if link.path == path %}focus active{% endif %}">
{{ link.name }}
</a>
{% endfor %}
</div>
</details>
{% endfor %}
<a href="{{rel_prefix | safe}}licenses.txt" class="_list-item">Open-source Licenses</a>
</div>
</div>
</section>
<devdocs-navbar current="{{path | safe}}" prefix="{{rel_prefix | safe}}" listing-src="{{rel_prefix | safe}}navbar.json"></devdocs-navbar>
<div class="_container">
<main class="_content">
<div class="_page _{{devdocs_metadata.slug_without_version | safe}}">{{content | safe}}</div>
</main>
</div>
</div>
<script type="text/javascript" src="{{rel_prefix | safe}}assets/index.js"></script>
</body>

</html>
Loading

0 comments on commit 94378e6

Please sign in to comment.