diff --git a/.gitignore b/.gitignore
index 8bed057..c642206 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
-### GIGI version 0.1.0
-### command with: python
+### GIGI version 0.2.1
+### command with: python node
### Python ###
# Byte-compiled / optimized / DLL files
@@ -172,3 +172,145 @@ poetry.toml
# LSP config files
pyrightconfig.json
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+.stylelintcache
+
+# SvelteKit build / generate output
+.svelte-kit
diff --git a/docs/_sass/demo.scss b/docs/_sass/demo.scss
new file mode 100644
index 0000000..dc1e5a9
--- /dev/null
+++ b/docs/_sass/demo.scss
@@ -0,0 +1,7 @@
+@use "bulma/sass/utilities/extends";
+
+.sass-demo-bulma-content {
+ @extend %control;
+ background-color: purple;
+ color: white;
+}
diff --git a/docs/conf.py b/docs/conf.py
index 3e52c24..f5b1561 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,6 +1,10 @@
+from pathlib import Path
+
from atsphinx.mini18n import get_template_dir as get_mini18n_template_dir
from atsphinx.toybox import __version__ as version
+root = Path(__file__).parent
+
# -- Project information
project = "atsphinx-toybox"
copyright = "2024, Kazuya Takei"
@@ -16,10 +20,11 @@
# Third-party extensions
"atsphinx.mini18n",
# My extensions
+ "atsphinx.toybox.sass",
"atsphinx.toybox.stlite",
]
templates_path = ["_templates", get_mini18n_template_dir()]
-exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "node_modules"]
# -- Options for i18n
gettext_compact = False
@@ -33,6 +38,7 @@
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/fontawesome.min.css",
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/solid.min.css",
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/brands.min.css",
+ "css/demo.css",
]
html_theme_options = {
"footer_icons": [
@@ -67,6 +73,9 @@
mini18n_default_language = "en"
mini18n_support_languages = ["en", "ja"]
mini18n_basepath = "/toybox/"
+# atsphinx.toybox.sass
+sass_load_paths = [root / "node_modules"]
+sass_extra_options = ["--update", "-q"]
def setup(app):
diff --git a/docs/extensions/sass/index.rst b/docs/extensions/sass/index.rst
new file mode 100644
index 0000000..b416551
--- /dev/null
+++ b/docs/extensions/sass/index.rst
@@ -0,0 +1,57 @@
+====================
+atsphinx.toybox.sass
+====================
+
+Overview
+========
+
+This compiles SASS/SCSS resources into css when run sphinx-build.
+This it to embed Stlite content into your documents.
+
+Usage
+=====
+
+Enable extension
+----------------
+
+Add this into your ``conf.py`` of Sphinx.
+
+.. code-block:: python
+ :caption: conf.py
+
+ extensions = [
+ "atsphinx.toybox.sass",
+ ]
+
+Configuration
+-------------
+
+There are not configuration options.
+
+.. note:: I will add some optioons.
+
+Demo
+====
+
+This content is used generated CSS from SASS.
+
+.. code:: rst
+
+ .. container:: sass-demo-bulma-content
+
+ This paragraph is colored by "purplel".
+
+.. literalinclude:: ../../_sass/demo.scss
+ :caption: docs/_sass/demo.scss
+
+Result:
+
+.. container:: sass-demo-bulma-content
+
+ This paragraph is colored by "purplel".
+
+Refs
+====
+
+* `dart-sacc CLI `_
+* `Repository `_
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 0000000..444e174
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "docs",
+ "version": "2024.12.2",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bulma": "^1.0.2"
+ }
+}
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
new file mode 100644
index 0000000..d6956d5
--- /dev/null
+++ b/docs/pnpm-lock.yaml
@@ -0,0 +1,22 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ bulma:
+ specifier: ^1.0.2
+ version: 1.0.2
+
+packages:
+
+ bulma@1.0.2:
+ resolution: {integrity: sha512-D7GnDuF6seb6HkcnRMM9E739QpEY9chDzzeFrHMyEns/EXyDJuQ0XA0KxbBl/B2NTsKSoDomW61jFGFaAxhK5A==}
+
+snapshots:
+
+ bulma@1.0.2: {}
diff --git a/pyproject.toml b/pyproject.toml
index 2772655..a8256bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,11 @@ dependencies = [
Home = "https://github.com/atsphinx/toybox"
Documentation = "https://atsphinx.github.io/toybox"
+[project.optional-dependencies]
+sass = [
+ "httpx>=0.28.0",
+]
+
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
diff --git a/src/atsphinx/toybox/sass/.gitignore b/src/atsphinx/toybox/sass/.gitignore
new file mode 100644
index 0000000..796a479
--- /dev/null
+++ b/src/atsphinx/toybox/sass/.gitignore
@@ -0,0 +1 @@
+_bin
diff --git a/src/atsphinx/toybox/sass/__init__.py b/src/atsphinx/toybox/sass/__init__.py
new file mode 100644
index 0000000..fd2ea8d
--- /dev/null
+++ b/src/atsphinx/toybox/sass/__init__.py
@@ -0,0 +1,66 @@
+"""Sass autobuild.
+
+Spec
+====
+
+* Use dart-sass binary
+* Auto find ``_sass`` directory.
+* Exclude ``_`` prefixed files.
+* Copy into _static
+* Timestamp based incremental build.
+"""
+
+import subprocess
+from pathlib import Path
+
+from sphinx.application import Sphinx
+from sphinx.config import Config
+
+from . import dart_sass
+
+SASS_VERSION = "1.81.0"
+
+package_root = Path(__file__).parent
+
+bin_dist = package_root / "_bin"
+sass_bin: Path
+
+
+def configure_sass_bin(app: Sphinx, config: Config):
+ """Set up sass executable. It download binary if it needs."""
+ global sass_bin
+ sass_bin = dart_sass.setup_dart_sass(SASS_VERSION, bin_dist)
+
+
+def compile_assets(app: Sphinx):
+ """Compile assets (sass or scss to css)."""
+ global sass_bin
+ source_dir = app.srcdir / "_sass"
+ output_dir = app.srcdir / "_static" / "css"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ options = []
+ if app.config.sass_load_paths:
+ options += [f"--load-path={p}" for p in app.config.sass_load_paths]
+ options += app.config.sass_extra_options
+ cmd = [str(sass_bin), *options, f"{source_dir}:{output_dir}"]
+ subprocess.run(cmd)
+
+
+def setup(app: Sphinx): # noqa: D103
+ app.add_config_value(
+ "sass_load_paths",
+ [],
+ "env",
+ list[str],
+ "Path list of external import resources",
+ )
+ app.add_config_value(
+ "sass_extra_options",
+ [],
+ "env",
+ list[str],
+ "Option arguments of dart-sass",
+ )
+ app.connect("config-inited", configure_sass_bin)
+ app.connect("builder-inited", compile_assets)
+ return {}
diff --git a/src/atsphinx/toybox/sass/dart_sass.py b/src/atsphinx/toybox/sass/dart_sass.py
new file mode 100644
index 0000000..07fc9e2
--- /dev/null
+++ b/src/atsphinx/toybox/sass/dart_sass.py
@@ -0,0 +1,99 @@
+"""dart-sass resolver."""
+
+import platform
+import shutil
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+import httpx
+from sphinx.util import logging
+
+OS_NAME = Literal["android", "linux", "macos", "windows"]
+ARCH_NAME = Literal[
+ "arm",
+ "arm-musl",
+ "arm64",
+ "arm64-musl",
+ "ia32",
+ "ia32-musl",
+ "riscv64",
+ "riscv64-musl",
+ "x64",
+ "x64-musl",
+]
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Release:
+ """Release base information for GitHub."""
+
+ os: OS_NAME
+ arch: ARCH_NAME
+ version: str
+
+ @classmethod
+ def from_platform(cls, version: str) -> "Release":
+ """Create object from runtime information."""
+ os_name = resolve_os()
+ arch_name = resolve_arch()
+ return cls(os=os_name, arch=arch_name, version=version)
+
+ @property
+ def archive_url(self) -> str:
+ """Retrieve URL for archive of GitHub Releases."""
+ return f"https://github.com/sass/dart-sass/releases/download/{self.version}/dart-sass-{self.version}-{self.os}-{self.arch}.{self.archive_ext}"
+
+ @property
+ def archive_format(self) -> str:
+ """String of ``shutil.unpack_archive``."""
+ return "zip" if self.os == "windows" else "gztar"
+
+ @property
+ def archive_ext(self) -> str:
+ """File format as extension."""
+ return "zip" if self.os == "windows" else "tar.gz"
+
+ @property
+ def exec_ext(self) -> str:
+ """Extension of executable file."""
+ return ".bat" if self.os == "windows" else ""
+
+
+def resolve_os() -> OS_NAME:
+ """Retrieve os name as dart-sass specified."""
+ os_name = platform.system()
+ if os_name == "Darwin":
+ return "macos"
+ if os_name in ("Linux", "Windows", "Android"):
+ return os_name.lower() # type: ignore[return-value]
+ raise Exception(f"There is not dart-sass binary for {os_name}")
+
+
+def resolve_arch() -> ARCH_NAME:
+ """Retrieve cpu architecture string as dart-sass specified."""
+ # NOTE: This logic is not all covered.
+ arch_name = platform.machine()
+ if arch_name in ("x86_64", "AMD64"):
+ arch_name = "x64"
+ if arch_name.startswith("arm") and arch_name != "arm64":
+ arch_name = "arm"
+ libc = platform.libc_ver()
+ arch_suffix = "-musi" if "musl" in libc[0] else ""
+ return f"{arch_name}{arch_suffix}" # type: ignore[return-value]
+
+
+def setup_dart_sass(version: str, dist: Path) -> Path:
+ """If executable is not exists, download archive."""
+ release = Release.from_platform(version)
+ fullpath = dist / "dart-sass" / f"sass{release.exec_ext}"
+ if not fullpath.exists():
+ logger.debug(f"Binary archive is {release.archive_url}")
+ resp = httpx.get(release.archive_url, follow_redirects=True)
+ archive_path = Path(tempfile.mktemp())
+ archive_path.write_bytes(resp.content)
+ shutil.unpack_archive(archive_path, dist, release.archive_format)
+ return fullpath
diff --git a/uv.lock b/uv.lock
index 5498bdd..f13460d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -45,6 +45,11 @@ dependencies = [
{ name = "sphinx" },
]
+[package.optional-dependencies]
+sass = [
+ { name = "httpx" },
+]
+
[package.dev-dependencies]
dev = [
{ name = "atsphinx-mini18n" },
@@ -58,7 +63,10 @@ dev = [
]
[package.metadata]
-requires-dist = [{ name = "sphinx" }]
+requires-dist = [
+ { name = "httpx", marker = "extra == 'sass'", specifier = ">=0.28.0" },
+ { name = "sphinx" },
+]
[package.metadata.requires-dev]
dev = [
@@ -303,6 +311,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/df/676b7cf674dd1bdc71a64ad393c89879f75e4a0ab8395165b498262ae106/httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0", size = 141307 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/fb/a19866137577ba60c6d8b69498dc36be479b13ba454f691348ddf428f185/httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc", size = 73551 },
+]
+
[[package]]
name = "idna"
version = "3.10"