PK vMXdp( ( sphinx_book_theme/__init__.py"""A lightweight book theme based on the pydata sphinx theme.""" import hashlib import os from pathlib import Path from functools import lru_cache from docutils import nodes as docutil_nodes from sphinx.application import Sphinx from sphinx.locale import get_translation from sphinx.util import logging from pydata_sphinx_theme.utils import get_theme_options_dict from .directives import Margin from .nodes import SideNoteNode from .header_buttons import ( prep_header_buttons, add_header_buttons, update_sourcename, update_context_with_repository_info, ) from .header_buttons.launch import add_launch_buttons from .header_buttons.source import add_source_buttons from ._transforms import HandleFootnoteTransform __version__ = "1.1.2" """sphinx-book-theme version""" SPHINX_LOGGER = logging.getLogger(__name__) DEFAULT_LOG_TYPE = "sphinxbooktheme" MESSAGE_CATALOG_NAME = "booktheme" def get_html_theme_path(): """Return list of HTML theme paths.""" parent = Path(__file__).parent.resolve() theme_path = parent / "theme" / "sphinx_book_theme" return theme_path def add_metadata_to_page(app, pagename, templatename, context, doctree): """Adds some metadata about the page that we reuse later.""" # Add the site title to our context so it can be inserted into the navbar if not context.get("root_doc"): # TODO: Sphinx renamed master to root in 4.x, deprecate when we drop 3.x context["root_doc"] = context.get("master_doc") context["root_title"] = app.env.titles[context["root_doc"]].astext() # Update the page title because HTML makes it into the page title occasionally if pagename in app.env.titles: title = app.env.titles[pagename] context["pagetitle"] = title.astext() # Add a shortened page text to the context using the sections text if doctree: description = "" for section in doctree.traverse(docutil_nodes.section): description += section.astext().replace("\n", " ") description = description[:160] context["page_description"] = description # Add the author if it exists if app.config.author != "unknown": context["author"] = app.config.author # Translations translation = get_translation(MESSAGE_CATALOG_NAME) context["translate"] = translation # If search text hasn't been manually specified, use a shorter one here theme_options = get_theme_options_dict(app) if "search_bar_text" not in theme_options: context["theme_search_bar_text"] = translation("Search") + "..." @lru_cache(maxsize=None) def _gen_hash(path: str) -> str: return hashlib.sha1(path.read_bytes()).hexdigest() def hash_assets_for_files(assets: list, theme_static: Path, context, app): """Generate a hash for assets, and append to its entry in context. assets: a list of assets to hash, each path should be relative to the theme's static folder. theme_static: a path to the theme's static folder. context: the Sphinx context object where asset links are stored. These are: `css_files` and `script_files` keys. """ for asset_path in assets: # CSS assets are stored in css_files, JS assets in script_files asset_type = "css_files" if asset_path.endswith(".css") else "script_files" if asset_type in context: # Define paths to the original asset file, and its linked file in Sphinx asset_source_path = theme_static / asset_path if not asset_source_path.exists(): SPHINX_LOGGER.warning( f"Asset {asset_source_path} does not exist, not linking." ) # Find this asset in context, and update it to include the digest for ii, other_asset in enumerate(context[asset_type]): # TODO: eventually the contents of context['css_files'] etc should probably # only be _CascadingStyleSheet etc. For now, assume mixed with strings. if getattr(other_asset, "filename", str(other_asset)) != asset_path: continue # Take priority from existing asset or use default priority (500) priority = getattr(other_asset, "priority", 500) # Remove existing asset del context[asset_type][ii] # Add new asset app.add_css_file( asset_path, digest=_gen_hash(asset_source_path), priority=priority, ) def hash_html_assets(app, pagename, templatename, context, doctree): """Add ?digest={hash} to assets in order to bust cache when changes are made. The source files are in `static` while the built HTML is in `_static`. """ assets = ["scripts/sphinx-book-theme.js"] # Only append the book theme CSS if it's explicitly this theme. Sub-themes # will define their own CSS file, so if a sub-theme is used, this code is # run but the book theme CSS file won't be linked in Sphinx. if app.config.html_theme == "sphinx_book_theme": assets.append("styles/sphinx-book-theme.css") hash_assets_for_files(assets, get_html_theme_path() / "static", context, app) def update_mode_thebe_config(app): """Update thebe configuration with SBT-specific values""" theme_options = get_theme_options_dict(app) if theme_options.get("launch_buttons", {}).get("thebe") is True: # In case somebody specifies they want thebe in a launch button # but has not activated the sphinx_thebe extension. if not hasattr(app.env.config, "thebe_config"): SPHINX_LOGGER.warning( ( "Thebe is activated but not added to extensions list. " "Add `sphinx_thebe` to your site's extensions list." ) ) return # Will be empty if it doesn't exist thebe_config = app.env.config.thebe_config else: return if not theme_options.get("launch_buttons", {}).get("thebe"): return # Update the repository branch and URL # Assume that if there's already a thebe_config, then we don't want to over-ride if "repository_url" not in thebe_config: thebe_config["repository_url"] = theme_options.get("repository_url") if "repository_branch" not in thebe_config: branch = theme_options.get("repository_branch") if not branch: # Explicitly check in case branch is "" branch = "master" thebe_config["repository_branch"] = branch app.env.config.thebe_config = thebe_config def check_deprecation_keys(app): """Warns about the deprecated keys.""" deprecated_config_list = ["single_page"] for key in deprecated_config_list: if key in get_theme_options_dict(app): SPHINX_LOGGER.warning( f"'{key}' was deprecated from version 0.3.4 onwards. See the CHANGELOG for more information: https://github.com/executablebooks/sphinx-book-theme/blob/master/CHANGELOG.md" # noqa: E501 f"[{DEFAULT_LOG_TYPE}]", type=DEFAULT_LOG_TYPE, ) def update_general_config(app, config): theme_dir = get_html_theme_path() config.templates_path.append(os.path.join(theme_dir, "components")) def update_templates(app, pagename, templatename, context, doctree): """Update template names and assets for page build. This is a copy of what the pydata theme does here to include a new section - https://github.com/pydata/pydata-sphinx-theme/blob/0a4894fab49befc59eb497811949a1d0ede626eb/src/pydata_sphinx_theme/__init__.py#L173 # noqa: E501 """ # Allow for more flexibility in template names template_sections = ["theme_footer_content_items"] for section in template_sections: if context.get(section): # Break apart `,` separated strings so we can use , in the defaults if isinstance(context.get(section), str): context[section] = [ ii.strip() for ii in context.get(section).split(",") ] # Add `.html` to templates with no suffix for ii, template in enumerate(context.get(section)): if not os.path.splitext(template)[1]: context[section][ii] = template + ".html" def setup(app: Sphinx): # Register theme theme_dir = get_html_theme_path() app.add_html_theme("sphinx_book_theme", theme_dir) app.add_js_file("scripts/sphinx-book-theme.js") # Translations locale_dir = os.path.join(theme_dir, "static", "locales") app.add_message_catalog(MESSAGE_CATALOG_NAME, locale_dir) # Events app.connect("builder-inited", update_mode_thebe_config) app.connect("builder-inited", check_deprecation_keys) app.connect("builder-inited", update_sourcename) app.connect("builder-inited", update_context_with_repository_info) app.connect("html-page-context", add_metadata_to_page) app.connect("html-page-context", hash_html_assets) app.connect("html-page-context", update_templates) # This extension has both theme-like and extension-like features. # Themes are initialised immediately before use, thus we cannot # rely on an event to set the config - the theme config must be # set in setup(app): update_general_config(app, app.config) # Meanwhile, extensions are initialised _first_, and any config # values set during setup() will be overwritten. We must therefore # register the `config-inited` event to set these config options app.connect("config-inited", update_general_config) # Nodes SideNoteNode.add_node(app) # Header buttons app.connect("html-page-context", prep_header_buttons) # Bump priority so that it runs after the pydata theme sets up the edit URL func. app.connect("html-page-context", add_launch_buttons, priority=501) app.connect("html-page-context", add_source_buttons, priority=501) app.connect("html-page-context", add_header_buttons, priority=501) # Directives app.add_directive("margin", Margin) # Post-transforms app.add_post_transform(HandleFootnoteTransform) return { "parallel_read_safe": True, "parallel_write_safe": True, } PK vMX/Q * sphinx_book_theme/_compile_translations.py"""Generate compiled static translation assets for Sphinx.""" import json import os from pathlib import Path import subprocess # In case the smodin.io code is different from the Sphinx code RENAME_LANGUAGE_CODES = { "zh-cn": "zh_CN", "zh-tw": "zh_TW", } def convert_json(folder=None): """Convert JSON translations into .mo/.po files for Sphinx. folder: the source folder of the JSON translations. This function will put the compiled .mo/.po files in a specific folder relative to this source folder. This parameter is just provided to make testing easier. """ # Raw translation JSONs that are hand-edited folder = folder or Path(__file__).parent / "assets" / "translations" # Location of compiled static translation assets out_folder = folder / ".." / ".." / "theme" / "sphinx_book_theme" / "static" # compile po for path in (folder / "jsons").glob("*.json"): data = json.loads(path.read_text("utf8")) assert data[0]["symbol"] == "en" english = data[0]["text"] for item in data[1:]: language = item["symbol"] if language in RENAME_LANGUAGE_CODES: language = RENAME_LANGUAGE_CODES[language] out_path = ( out_folder / "locales" / language / "LC_MESSAGES" / "booktheme.po" # noqa: E501 ) if not out_path.parent.exists(): out_path.parent.mkdir(parents=True) if not out_path.exists(): header = f""" msgid "" msgstr "" "Project-Id-Version: Sphinx-Book-Theme\\n" "MIME-Version: 1.0\\n" "Content-Type: text/plain; charset=UTF-8\\n" "Content-Transfer-Encoding: 8bit\\n" "Language: {language}\\n" "Plural-Forms: nplurals=2; plural=(n != 1);\\n" """ out_path.write_text(header) with out_path.open("a") as f: f.write("\n") f.write(f'msgid "{english}"\n') text = item["text"].replace('"', '\\"') f.write(f'msgstr "{text}"\n') # compile mo for path in (out_folder / "locales").glob("**/booktheme.po"): print(path) subprocess.check_call( [ "msgfmt", os.path.abspath(path), "-o", os.path.abspath(path.parent / "booktheme.mo"), ] ) if __name__ == "__main__": print("[SBT]: Compiling translations") convert_json() PK vMX `> > sphinx_book_theme/_transforms.pyfrom sphinx.transforms.post_transforms import SphinxPostTransform from typing import Any from docutils import nodes as docutil_nodes from sphinx import addnodes as sphinx_nodes from pydata_sphinx_theme.utils import get_theme_options_dict from .nodes import SideNoteNode class HandleFootnoteTransform(SphinxPostTransform): """Transform footnotes into side/marginnotes.""" default_priority = 1 formats = ("html",) def run(self, **kwargs: Any) -> None: theme_options = get_theme_options_dict(self.app) if theme_options.get("use_sidenotes", False) is False: return None # Cycle through footnote references, and move their content next to the # reference. This lets us display the reference in the margin, # or just below on narrow screens. for ref_node in self.document.traverse(docutil_nodes.footnote_reference): parent = None # Each footnote reference should have a single node it points to via `ids` for foot_node in self.document.traverse(docutil_nodes.footnote): # matching the footnote reference with footnote if ( len(foot_node.attributes["backrefs"]) and foot_node.attributes["backrefs"][0] == ref_node.attributes["ids"][0] ): parent = foot_node.parent # second children of footnote node is the content text foot_node_content = foot_node.children[1].children sidenote = SideNoteNode() para = docutil_nodes.inline() # first children of footnote node is the label label = foot_node.children[0].astext() if foot_node_content[0].astext().startswith("{-}"): # marginnotes will have content starting with {-} # remove the number so it doesn't show para.attributes["classes"].append("marginnote") foot_node_content[0] = docutil_nodes.Text( foot_node_content[0].replace("{-}", "") ) para.children = foot_node_content sidenote.attributes["names"].append(f"marginnote-role-{label}") else: # sidenotes are the default behavior if no {-} # in this case we keep the number superscript = docutil_nodes.superscript("", label) para.attributes["classes"].append("sidenote") parachildren = [superscript] + foot_node_content para.children = parachildren sidenote.attributes["names"].append(f"sidenote-role-{label}") sidenote.append(superscript) # If the reference is nested (e.g. in an admonition), duplicate # the content node And place it just before the parent container, # so it works w/ margin. Only show one or another depending on # screen width. node_parent = ref_node.parent para_dup = para.deepcopy() # looping to check parent node while not isinstance( node_parent, (docutil_nodes.section, sphinx_nodes.document) ): # if parent node is another container if not isinstance( node_parent, (docutil_nodes.paragraph, docutil_nodes.footnote), ): node_parent.replace_self([para, node_parent]) para_dup.attributes["classes"].append("d-n") break node_parent = node_parent.parent ref_node.replace_self([sidenote, para_dup]) break if parent: parent.remove(foot_node) PK vMXuC ) sphinx_book_theme/assets/scripts/index.js// Import CSS variables // ref: https://css-tricks.com/getting-javascript-to-talk-to-css-and-sass/ import "../styles/index.scss"; /** * A helper function to load scripts when the DOM is loaded. * This waits for everything to be on the page first before running, since * some functionality doesn't behave properly until everything is ready. */ var sbRunWhenDOMLoaded = (cb) => { if (document.readyState != "loading") { cb(); } else if (document.addEventListener) { document.addEventListener("DOMContentLoaded", cb); } else { document.attachEvent("onreadystatechange", function () { if (document.readyState == "complete") cb(); }); } }; /** * Toggle full-screen with button * * There are some browser-specific hacks in here: * - Safari requires a `webkit` prefix, so this uses conditionals to check for that * ref: https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API */ var toggleFullScreen = () => { var isInFullScreen = (document.fullscreenElement && document.fullscreenElement !== null) || (document.webkitFullscreenElement && document.webkitFullscreenElement !== null); let docElm = document.documentElement; if (!isInFullScreen) { console.log("[SBT]: Entering full screen"); if (docElm.requestFullscreen) { docElm.requestFullscreen(); } else if (docElm.webkitRequestFullscreen) { docElm.webkitRequestFullscreen(); } } else { console.log("[SBT]: Exiting full screen"); if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } }; /** * Manage scrolling behavior. This is primarily two things: * * 1. Hide the Table of Contents any time sidebar content is on the screen. * * This will be triggered any time a sidebar item enters or exits the screen. * It adds/removes items from an array if they have entered the screen, and * removes them when they exit the screen. It hides the TOC if anything is * on-screen. * * ref: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API * * 2. Add a `scrolled` class to
to trigger CSS changes. */ var initTocHide = () => { var onScreenItems = []; let hideTocCallback = (entries, observer) => { // Check whether any sidebar item is displayed entries.forEach((entry) => { if (entry.isIntersecting) { // If an element just came on screen, add it our list onScreenItems.push(entry.target); } else { // Otherwise, if it's in our list then remove it for (let ii = 0; ii < onScreenItems.length; ii++) { if (onScreenItems[ii] === entry.target) { onScreenItems.splice(ii, 1); break; } } } }); // Hide the TOC if any margin content is displayed on the screen if (onScreenItems.length > 0) { document.querySelector("div.bd-sidebar-secondary").classList.add("hide"); } else { document .querySelector("div.bd-sidebar-secondary") .classList.remove("hide"); } }; let manageScrolledClassOnBody = (entries, observer) => { // The pixel is at the top, so if we're < 0 that it means we've scrolled if (entries[0].boundingClientRect.y < 0) { document.body.classList.add("scrolled"); } else { document.body.classList.remove("scrolled"); } }; // Set up the intersection observer to watch all margin content let options = { // Trigger callback when the top of a margin item is 1/3 up the screen rootMargin: "0px 0px -33% 0px", }; let tocObserver = new IntersectionObserver(hideTocCallback, options); // TODO: deprecate popout after v0.5.0 const selectorClasses = [ "marginnote", "sidenote", "margin", "margin-caption", "full-width", "sidebar", "popout", ]; let marginSelector = []; selectorClasses.forEach((ii) => { // Use three permutations of each class name because `tag_` and `_` used to be supported marginSelector.push( ...[ `.${ii}`, `.tag_${ii}`, `.${ii.replace("-", "_")}`, `.tag_${ii.replace("-", "_")}`, ], ); }); document.querySelectorAll(marginSelector.join(", ")).forEach((ii) => { tocObserver.observe(ii); }); // Set up the observer to check if we've scrolled from top of page let scrollObserver = new IntersectionObserver(manageScrolledClassOnBody); scrollObserver.observe(document.querySelector(".sbt-scroll-pixel-helper")); }; /** * Activate Thebe with a custom button click. */ var initThebeSBT = () => { var title = document.querySelector("section h1"); var sibling = title.nextElementSibling; // If the next element after the title isn't a thebe button, add one now. // That way it is initiatlized when thebe is first-clicked and isn't re-added after. if (!sibling.classList.contains("thebe-launch-button")) { title.insertAdjacentHTML( "afterend", "", ); } // This function is provided by sphinx-thebe initThebe(); }; /** * Add no print class to certain DOM elements */ function addNoPrint() { var noPrintSelector = [ ".bd-header-announcement", ".bd-header", ".bd-header-article", ".bd-sidebar-primary", ".bd-sidebar-secondary", ".bd-footer-article", ".bd-footer-content", ".bd-footer", ].join(","); document.querySelectorAll(noPrintSelector).forEach((ii) => { ii.classList.add("noprint"); }); } /** * Set up callback functions for UI click actions */ window.initThebeSBT = initThebeSBT; window.toggleFullScreen = toggleFullScreen; /** * Set up functions to load when the DOM is ready */ sbRunWhenDOMLoaded(initTocHide); sbRunWhenDOMLoaded(addNoPrint); PK vMX.|X X 6 sphinx_book_theme/assets/styles/abstracts/_mixins.scss/********************************************* * SASS Mixins *********************************************/ /** * Hide the scrollbar until the element is hovered, so keep the page clean * Use this sparingly because it's not a great UX pattern. */ @mixin scrollbar-on-hover() { &:not(:hover) { &::-webkit-scrollbar-thumb { visibility: hidden; } } } /** * The PyData Sphinx Theme box shadow rule * Copied here in csae we need to re-use. */ @mixin pst-box-shadow() { box-shadow: 0 0.2rem 0.5rem var(--pst-color-shadow), 0 0 0.0625rem var(--pst-color-shadow) !important; } PK vMX^ 9 sphinx_book_theme/assets/styles/abstracts/_variables.scss/********************************************* * Variables * *********************************************/ // Breakpoints from Bootstrap: https://getbootstrap.com/docs/5.0/layout/breakpoints/ $breakpoint-xxl: 1200px; $breakpoint-xl: 1200px; $breakpoint-lg: 992px; $breakpoint-md: 768px; $breakpoint-sm: 576px; // A few semantic z-indices $zindex-bottom: 1; $zindex-middle: 2; $zindex-top: 3; // Semantic Z-index from bootstrap. Copied here so we can re-use if needed. // ref: https://getbootstrap.com/docs/5.0/layout/z-index/ $zindex-dropdown: 1000; $zindex-sticky: 1020; $zindex-fixed: 1030; $zindex-modal-backdrop: 1040; $zindex-offcanvas: 1050; $zindex-modal: 1060; $zindex-popover: 1070; $zindex-tooltip: 1080; // Spacing $header-article-height: 3rem; $sidebar-primary-width-widescreen: 20%; $toc-width-mobile: 75%; // Consistent styling for page elements $box-border-radius: 0.4em; $animation-time: 0.25s; // Font sizes $sbt-font-size-small-1: 87.5%; /** * Variables that aren't impacted by light/dark */ html[data-theme="light"], html[data-theme="dark"] { // Over-ride the pydata theme so that readers can use their system base --pst-font-size-base: none; // Default secondary color (has enough contrast on both light/dark so // no need to special case. --pst-color-secondary: #e89217; } // Overrides for pydata sphinx theme. // See https://github.com/pydata/pydata-sphinx-theme/blob/master/pydata_sphinx_theme/static/css/theme.css html[data-theme="light"] { // Announcement --sbt-color-announcement: rgb(97, 97, 97); // Default primary color (need to adjust on dark theme due to bad contrast) --pst-color-primary: #176de8; } html[data-theme="dark"] { // Slightly lighten these colors to make them stand out more on dark --pst-color-primary: #528fe4; // Over-ride the purple announcement color --sbt-color-announcement: rgb(97, 97, 97); // Desaturate the background --pst-color-background: #121212; } PK vMX?t / sphinx_book_theme/assets/styles/base/_base.scss/** * General structural things */ html { // The PyData theme value for this is based on `header-height` variable. // We set the variable to 0 and have our own $article-header-height SCSS variable. // So here we follow the same pattern but now using our variable. scroll-padding-top: $header-article-height + 1rem; } /** * Utility classes used in a few places */ // For the helper pixel that we can watch to decide whether we've scrolled .sbt-scroll-pixel-helper { position: absolute; width: 0px; height: 0px; top: 0; left: 0; } // We define our own display-none class since bootstrap uses !important and we want to be able to over-ride .d-n { display: none; } /** * Printing behavior */ // Only display upon printing .onlyprint { display: none; @media print { display: block !important; } } // Prevent an item from being printed .noprint { @media print { display: none !important; } } PK vMX>6X X 0 sphinx_book_theme/assets/styles/base/_print.scss/********************************************* * Print-specific CSS * *********************************************/ @media print { .bd-main { .bd-content { margin-left: 2rem; height: auto; // Structural elements #jb-print-docs-body { margin-left: 0rem; h1 { font-size: 3em; text-align: center; margin-bottom: 0; } } // Main content adjustments .bd-article { padding-top: 0; // The first H1 is the title, we've already displayed above h1:first-of-type { display: none; } } // HACK: Without this, some code cells take the whole width .container { min-width: 0% !important; } // Content h1 { margin-top: 1em; margin-bottom: 1em; } h1, h2, h3, h4 { break-after: avoid; color: black; } table { break-inside: avoid; } pre { word-wrap: break-word; } a.headerlink { display: none; } // Remove borders to save some ink blockquote.epigraph, aside.margin, aside.sidebar { border: none; } .footer { margin-top: 1em; } // Print-only table of contents #jb-print-toc { margin-bottom: 1.5rem; margin-left: 0rem; .section-nav { border-left: 0px !important; list-style-type: disc !important; margin-left: 3em !important; a { text-decoration: none !important; } li { display: list-item !important; } .nav { display: none; } } } } // Hide the footer on printing .bd-footer-content { display: none !important; } } } PK vMX0 5 sphinx_book_theme/assets/styles/base/_typography.scss/********************************************* * Basic text formatting and content structure * *********************************************/ .bd-article-container { h1, h2, h3, h4, h5, p.caption { color: var(--pst-color-muted); } // Top two headers are slightly bolder h1, h2 { font-weight: 500; } } // counteracting pydata style on a::before, for citation style a.brackets::before { color: inherit; font-family: inherit; margin-right: 0rem; } table { position: relative; } PK vMXiE E <