feat(gen-docs): add Hugo partials docs generation (#809)

* feat(gen-docs): add documentation generation package with SassDoc and TypeDoc support

Add new `@hugo-fixit/gen-docs` package that parses Hugo config (hugo.toml) and generates
documentation. Includes SassDoc annotations for SCSS variables and mixins, TypeDoc config
for TypeScript source docs, and a `.sassdocrc` configuration file.

* docs(readme): add browsers support section

* feat(gen-docs): add Hugo partials docs generation and fix config empty sections

- Add partial-parser.ts: parses Hugo partial comment blocks (@param/@return/@example)
- Add partials.ts renderer: generates grouped Markdown with param tables
- Add `partials` subcommand with -t/-o options for template injection
- Fix config-parser: empty TOML sections (library.js/library.css) now retain descriptions
- Fix config-parser: correctly distinguish section-level vs key-level comments
- Update README with partials subcommand documentation
This commit is contained in:
Cell
2026-07-21 12:19:07 +08:00
committed by GitHub
parent e07eefd506
commit 2c0b48370b
40 changed files with 2317 additions and 955 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"display": {
"access": ["public"],
"alias": false
},
"sort": ["group"]
}
+11 -2
View File
@@ -109,7 +109,7 @@ Click the following links to generate a new repository with template:
- Globally consistent **design language** - Globally consistent **design language**
- **Pagination** supported - **Pagination** supported
- Easy-to-use and self-expanding **table of contents** - Easy-to-use and self-expanding **table of contents**
- **Multilanguage** supported and i18n ready - **Multilanguage** supported and I18n ready
- Beautiful **CSS animation** - Beautiful **CSS animation**
### Social and Comment Systems ### Social and Comment Systems
@@ -168,7 +168,7 @@ Click the following links to generate a new repository with template:
</details> </details>
## Multilingual and i18n ## Multilingual and I18n
FixIt supports multilingual and i18n. For more information, see the [Content Management](https://fixit.lruihao.cn/docs/content-management/introduction#multilingual) documentation. You are welcome to [contribute with a new language](https://github.com/hugo-fixit/FixIt/pulls). FixIt supports multilingual and i18n. For more information, see the [Content Management](https://fixit.lruihao.cn/docs/content-management/introduction#multilingual) documentation. You are welcome to [contribute with a new language](https://github.com/hugo-fixit/FixIt/pulls).
@@ -194,6 +194,15 @@ FixIt supports multilingual and i18n. For more information, see the [Content Man
</details> </details>
## Browsers Support
The FixIt theme supports the last two versions of all major browsers.
![Chrome](https://img.shields.io/badge/Chrome-last+2-blue?logo=googlechrome)
![Edge](https://img.shields.io/badge/Edge-last+2-blue?logo=microsoft-edge)
![Firefox](https://img.shields.io/badge/Firefox-last+2-orange?logo=firefox)
![Safari](https://img.shields.io/badge/Safari-last+2-blue?logo=safari)
## Theme Components ## Theme Components
To balance **simplicity** and **extensibility**, FixIt theme provides extra [Hugo theme components](https://fixit.lruihao.cn/ecosystem/#-components) for customization. To balance **simplicity** and **extensibility**, FixIt theme provides extra [Hugo theme components](https://fixit.lruihao.cn/ecosystem/#-components) for customization.
+9
View File
@@ -194,6 +194,15 @@ FixIt 主题多语言基本配置及自动翻译等详见 [内容管理](https:/
</details> </details>
## 浏览器支持
FixIt 主题支持所有主流浏览器的最近两个版本。
![Chrome](https://img.shields.io/badge/Chrome-last+2-blue?logo=googlechrome)
![Edge](https://img.shields.io/badge/Edge-last+2-blue?logo=microsoft-edge)
![Firefox](https://img.shields.io/badge/Firefox-last+2-orange?logo=firefox)
![Safari](https://img.shields.io/badge/Safari-last+2-blue?logo=safari)
## 主题组件 ## 主题组件
为了平衡 FixIt 主题的 **简洁性****可扩展性**,我们额外开发了一系列 [Hugo 主题组件](https://fixit.lruihao.cn/zh-cn/ecosystem/#-组件) 供用户选择。 为了平衡 FixIt 主题的 **简洁性****可扩展性**,我们额外开发了一系列 [Hugo 主题组件](https://fixit.lruihao.cn/zh-cn/ecosystem/#-组件) 供用户选择。
+2 -1
View File
@@ -15,7 +15,8 @@ export type FixItDocumentEventMap = {
[K in keyof FixItEventMap]: CustomEvent<FixItEventMap[K]> [K in keyof FixItEventMap]: CustomEvent<FixItEventMap[K]>
} }
type Handler<T> = T extends void /** Event handler type — infers the correct signature from the event payload. */
export type Handler<T> = T extends void
? (() => void) | ((event: CustomEvent<void>) => void) ? (() => void) | ((event: CustomEvent<void>) => void)
: (event: CustomEvent<T>) => void : (event: CustomEvent<T>) => void
+8
View File
@@ -8,6 +8,14 @@
@forward "hugo:vars"; @forward "hugo:vars";
@forward "hugo:vars/internal"; @forward "hugo:vars/internal";
/// CSS variable prefix for all custom properties.
/// @type String
$prefix: fi- !default; $prefix: fi- !default;
/// Root prefix for CSS custom properties (derived from $prefix).
/// @type String
$rootPrefix: --#{$prefix} !default; $rootPrefix: --#{$prefix} !default;
/// Default header height.
/// @type Length
$header-height: 3.5rem !default; $header-height: 3.5rem !default;
+2 -1
View File
@@ -1,4 +1,5 @@
// Color map of admonitions /// Color map of admonitions.
/// @type Map
$admonition-color-map: ( $admonition-color-map: (
note: ( note: (
color: #448aff, color: #448aff,
+4 -1
View File
@@ -1,4 +1,5 @@
// Color map of basic alerts /// Color map of GitHub-style alerts (light theme).
/// @type Map
$alert-color-map: ( $alert-color-map: (
note: ( note: (
border-color: #0969da, border-color: #0969da,
@@ -22,6 +23,8 @@ $alert-color-map: (
), ),
) !default; ) !default;
/// Color map of GitHub-style alerts (dark theme).
/// @type Map
$alert-color-map-dark: ( $alert-color-map-dark: (
note: ( note: (
border-color: #316dca, border-color: #316dca,
+1 -1
View File
@@ -1,6 +1,6 @@
/// Map of code identifiers/languages to Chroma supported languages. /// Map of code identifiers/languages to Chroma supported languages.
/// This file is auto-generated by `pnpm gen:lexers`, do not edit manually. /// This file is auto-generated by `pnpm gen:lexers`, do not edit manually.
/// @see https://github.com/hugo-fixit/FixIt/tree/main/packages/chroma-lexers /// See: https://github.com/hugo-fixit/FixIt/tree/main/packages/chroma-lexers
$chroma-lexers: ( $chroma-lexers: (
"🔥": "Mojo", "🔥": "Mojo",
"1s": "OnesEnterprise", "1s": "OnesEnterprise",
+2 -1
View File
@@ -1,4 +1,5 @@
// Theme colors map /// Theme semantic color tokens.
/// @type Map
$theme-colors: ( $theme-colors: (
'primary': #1677ff, 'primary': #1677ff,
'secondary': #8b949e, 'secondary': #8b949e,
+6 -1
View File
@@ -1,6 +1,8 @@
@use "core/functions" as *; @use "core/functions" as *;
// TODO Superseded by text-wrap: wrap; in modern browsers /// Apply word-wrap and overflow-wrap for cross-browser compatibility.
/// TODO Superseded by `text-wrap: wrap;` in modern browsers.
/// @param {String} $value - Wrap value (e.g. `break-word`)
@mixin overflow-wrap($value) { @mixin overflow-wrap($value) {
word-wrap: $value; word-wrap: $value;
overflow-wrap: $value; overflow-wrap: $value;
@@ -43,6 +45,9 @@
} }
} }
/// Apply a focus-visible ring outline using a pseudo-element.
/// @param {Length} $size [$size: 1.3em] - Size of the focus ring
/// @param {Length} $offset [$offset: 0.2rem] - Offset from the element
@mixin focus-visible-ring($size: 1.3em, $offset: 0.2rem) { @mixin focus-visible-ring($size: 1.3em, $offset: 0.2rem) {
&:focus-visible { &:focus-visible {
outline: none; outline: none;
@@ -2,6 +2,7 @@
@use "border-radius" as *; @use "border-radius" as *;
@use "compatibility" as *; @use "compatibility" as *;
/// Shared inline code styles using theme CSS variables.
@mixin inline-code() { @mixin inline-code() {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
margin: 0; margin: 0;
+1 -1
View File
@@ -1,7 +1,7 @@
@use "core/functions" as *; @use "core/functions" as *;
@use "z-index" as *; @use "z-index" as *;
// Loading Mixin for before/after pseudo element /// Loading pseudo-element styles for async content.
@mixin loading { @mixin loading {
content: ''; content: '';
position: absolute; position: absolute;
+2
View File
@@ -11,6 +11,8 @@
@use "sass:map"; @use "sass:map";
@use "sass:list"; @use "sass:list";
/// Responsive breakpoints map for the `media` mixin.
/// @type Map
$breakpoints: ( $breakpoints: (
'xs': null, 'xs': null,
'sm': 680px, 'sm': 680px,
@@ -1,5 +1,8 @@
@use "theme-vars" as *; @use "theme-vars" as *;
/// Set scrollbar width CSS custom properties.
/// @param {String} $width [$width: thin] - Scrollbar width value
/// @param {Length} $widthLegacy [$widthLegacy: 12px] - Legacy scrollbar width
@mixin scrollbar-width($width: thin, $widthLegacy: 12px) { @mixin scrollbar-width($width: thin, $widthLegacy: 12px) {
@include set-fi-vars(( @include set-fi-vars((
scrollbar-width: $width, scrollbar-width: $width,
+5 -2
View File
@@ -1,8 +1,11 @@
@use "variables" as *; @use "variables" as *;
// Shared defaults for theme-switch icon motion. /// Duration of the theme-switch icon animation.
// Keep them overridable so other entries can reuse the same mixin with different pacing. /// @type Time
$theme-switch-animation-duration: 0.5s !default; $theme-switch-animation-duration: 0.5s !default;
/// Easing function for the theme-switch icon animation.
/// @type String
$theme-switch-animation-easing: cubic-bezier(0.22, 1, 0.36, 1) !default; $theme-switch-animation-easing: cubic-bezier(0.22, 1, 0.36, 1) !default;
/// Bind a theme mode selector to an icon glyph and its matching animation. /// Bind a theme mode selector to an icon glyph and its matching animation.
+2
View File
@@ -1,5 +1,7 @@
@use "sass:map"; @use "sass:map";
/// Z-index scale for consistent layering across the theme.
/// @type Map
$z-indexes: ( $z-indexes: (
"hide": -1, "hide": -1,
"auto": auto, "auto": auto,
+10 -10
View File
@@ -676,7 +676,7 @@ wrapper = true
cdn = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs" cdn = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs"
# ZenUML ESM module CDN source. # ZenUML ESM module CDN source.
# You can set it to a CDN source to enable ZenUML support. # You can set it to a CDN source to enable ZenUML support.
# Example: `https://cdn.jsdelivr.net/npm/@mermaid-js/mermaid-zenuml/dist/mermaid-zenuml.esm.min.mjs`. # Example: `https://cdn.jsdelivr.net/npm/@mermaid-js/mermaid-zenuml/dist/mermaid-zenuml.esm.min.mjs`
zenuml = "" zenuml = ""
# For values, See: [Available Themes](https://mermaid.js.org/config/theming.html#available-themes). # For values, See: [Available Themes](https://mermaid.js.org/config/theming.html#available-themes).
themes = [ themes = [
@@ -692,7 +692,7 @@ look = "handDrawn"
# Specifies the font to be used in the rendered diagrams. # Specifies the font to be used in the rendered diagrams.
font_family = "" font_family = ""
# Layout loaders from ESM module CDN sources (e.g., ELK layout engine). # Layout loaders from ESM module CDN sources (e.g., ELK layout engine).
# Example: ["https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs"]. # Example: ["https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs"]
# This will enable additional layout options: ["elk", "elk.layered", "elk.stress", "elk.force", "elk.mrtree"]. # This will enable additional layout options: ["elk", "elk.layered", "elk.stress", "elk.force", "elk.mrtree"].
layout_loaders = [] layout_loaders = []
# Default layout algorithm for rendering diagrams. # Default layout algorithm for rendering diagrams.
@@ -859,9 +859,9 @@ user_desc = ""
# DOM container to be blacked out, e.g. [".aplayer"] # DOM container to be blacked out, e.g. [".aplayer"]
black_dom = [] black_dom = []
# only for iframe mode # only for iframe mode
# Example: "375px". # Example: "375px"
frame_width = "" frame_width = ""
# Example: "600px". # Example: "600px"
frame_height = "" frame_height = ""
# Only for magic mode. # Only for magic mode.
user_icon = "" user_icon = ""
@@ -1163,21 +1163,21 @@ lazy_load = true
[params.library] [params.library]
# Load some CSS from local assets. # Load some CSS from local assets.
# Example: someCSS = "css/some.css". # Example: `someCSS = "css/some.css"`
# Load some CSS from remote CDN. # Load some CSS from remote CDN.
# Example: someCSS = "https://cdn.example.com/some.css". # Example: `someCSS = "https://cdn.example.com/some.css"`
[params.library.css] [params.library.css]
# Load some JS from local assets. # Load some JS from local assets.
# Example: someJS = "js/some.js". # Example: `someJS = "js/some.js"`
# Load some JS from remote CDN. # Load some JS from remote CDN.
# Example: someJS = "https://cdn.example.com/some.js". # Example: `someJS = "https://cdn.example.com/some.js"`
[params.library.js] [params.library.js]
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Public Git repository information only then enableGitInfo is true. # Public Git repository information only then enableGitInfo is true.
[params.git_info] [params.git_info]
# Example: "https://github.com/hugo-fixit/docs". # Example: `"https://github.com/hugo-fixit/docs"`
repo = "" repo = ""
branch = "main" branch = "main"
# The content directory path relative to the root of the repository. # The content directory path relative to the root of the repository.
@@ -1373,7 +1373,7 @@ enable = false
animate = true animate = true
icon = "fa-solid fa-heartbeat" icon = "fa-solid fa-heartbeat"
pre = "" pre = ""
# Example: "2021-12-18T16:15:22+08:00". # Example: "2021-12-18T16:15:22+08:00"
value = "" value = ""
# Footer lines order. # Footer lines order.
+1 -1
View File
@@ -4,7 +4,7 @@
Loads the main stylesheet (every page) and conditionally loads Loads the main stylesheet (every page) and conditionally loads
page-specific CSS based on .Kind, .Layout, and .RelPermalink. page-specific CSS based on .Kind, .Layout, and .RelPermalink.
Called from: base/head/index.html Called from: `base/head/index.html`
*/ -}} */ -}}
{{- $scssVars := partialCached "function/scss-vars.html" . -}} {{- $scssVars := partialCached "function/scss-vars.html" . -}}
@@ -24,7 +24,7 @@
@example @example
// Practical usage in templates // Practical usage in templates
{{- $siteParams := partial "function/camel-case-keys.html" .Site.Params }} {{- $siteParams := partial "function/camel-case-keys.html" .Site.Params -}}
*/ -}} */ -}}
{{- $input := . | default dict -}} {{- $input := . | default dict -}}
{{- $output := dict -}} {{- $output := dict -}}
+3 -1
View File
@@ -5,7 +5,9 @@
@param {String} . - The snake_case string @param {String} . - The snake_case string
@return {String} The camelCase string @return {String} The camelCase string
@example "max_shown_lines" -> "maxShownLines" @example
// "max_shown_lines" -> "maxShownLines"
{{- partial "function/camel-case.html" "max_shown_lines" -}}
*/ -}} */ -}}
{{- $parts := split . "_" -}} {{- $parts := split . "_" -}}
{{- $result := index $parts 0 -}} {{- $result := index $parts 0 -}}
+5 -2
View File
@@ -1,5 +1,8 @@
{{- /* Escape url special characters to query format, e.g: `#` -> `%23` */ -}} {{- /*
{{- /* https://github.com/hugo-fixit/FixIt/issues/245 */ -}} Escape URL special characters to query format, e.g: `#` -> `%23`
See: https://github.com/hugo-fixit/FixIt/issues/245
*/ -}}
{{- $content := . -}} {{- $content := . -}}
{{- $content = replace $content "#" "%23" -}} {{- $content = replace $content "#" "%23" -}}
+2 -1
View File
@@ -21,8 +21,9 @@
@example @example
{{- $cover := dict "Page" . | partial "function/get-cover.html" -}} {{- $cover := dict "Page" . | partial "function/get-cover.html" -}}
{{- with $cover.URL }}<img src="{{ . }}">{{ end -}} {{- with $cover.URL -}}<img src="{{ . }}">{{- end -}}
@example
{{- $cover := dict "Page" . "Preview" true | partial "function/get-cover.html" -}} {{- $cover := dict "Page" . "Preview" true | partial "function/get-cover.html" -}}
{{- dict "Src" $cover.URL "Resources" .Resources "Matches" $cover.Matches | partial "plugin/image.html" -}} {{- dict "Src" $cover.URL "Resources" .Resources "Matches" $cover.Matches | partial "plugin/image.html" -}}
*/ -}} */ -}}
@@ -1,7 +1,12 @@
{{- /* {{- /*
Example: Validate if the given path has a valid suffix.
{{- $suffixList := slice ".jpeg" ".jpg" ".png" ".gif" ".bmp" ".tif" ".tiff" ".webp" ".avif" ".svg" -}} @param Path string The path to validate.
{{- $suffixValid := (dict "Path" .Path "Suffixes" $suffixList | partial "function/suffix-validation.html") -}} @param Suffixes []string The list of valid suffixes.
@return bool True if the path has a valid suffix, false otherwise.
@example
{{- $suffixList := slice ".jpeg" ".jpg" ".png" ".gif" ".bmp" ".tif" ".tiff" ".webp" ".avif" ".svg" -}}
{{- $suffixValid := (dict "Path" .Path "Suffixes" $suffixList | partial "function/suffix-validation.html") -}}
*/ -}} */ -}}
{{- $url := urls.Parse .Path -}} {{- $url := urls.Parse .Path -}}
{{- $path := path.Clean ((printf "%v%v" $url.Host $url.Path) | lower) -}} {{- $path := path.Clean ((printf "%v%v" $url.Host $url.Path) | lower) -}}
+2 -2
View File
@@ -1,9 +1,9 @@
{{- /* {{- /*
Icon renderer for Font Awesome classes and SVG sources. Icon renderer for Font Awesome classes and SVG sources.
- Renders <i> element when Class is provided. - Renders `<i>` element when `.Class` is provided.
- Resolves local SVG resources or external SVG URL fallback. - Resolves local SVG resources or external SVG URL fallback.
- Supports Simple Icons shorthand by icon name. - Supports Simple Icons shorthand by icon name.
@param {String} [.Class] - Icon class name for <i> @param {String} [.Class] - Icon class name for `<i>`
@param {String} [.Src] - SVG source path or URL @param {String} [.Src] - SVG source path or URL
@param {String} [.Simpleicons] - Simple-icons icon name @param {String} [.Simpleicons] - Simple-icons icon name
@param {String} [.Prefix] - Custom prefix for simple-icons source path @param {String} [.Prefix] - Custom prefix for simple-icons source path
+1 -1
View File
@@ -1,6 +1,6 @@
{{- /* {{- /*
Social profile link helper. Social profile link helper.
- Builds destination from explicit Url or from Prefix/Template/Id. - Builds destination from explicit URL or from Prefix/Template/Id.
- Delegates final rendering to plugin/link.html with rel=me. - Delegates final rendering to plugin/link.html with rel=me.
@param {String} [.Url] - Explicit target URL @param {String} [.Url] - Explicit target URL
@param {String} [.Template] - URL template containing one %%v placeholder @param {String} [.Template] - URL template containing one %%v placeholder
+1 -1
View File
@@ -1,6 +1,6 @@
{{- /* {{- /*
Stylesheet tag renderer. Stylesheet tag renderer.
- Accepts direct <link> HTML, a pre-built resource, or builds from source path. - Accepts direct `<link>` HTML, a pre-built resource, or builds from source path.
- Supports template execution, optional toCSS, minify/fingerprint, and preload mode. - Supports template execution, optional toCSS, minify/fingerprint, and preload mode.
@param {String} [.Source] - Stylesheet URL/path; if starts with "<link" it's treated as raw HTML @param {String} [.Source] - Stylesheet URL/path; if starts with "<link" it's treated as raw HTML
@param {resource.Resource} [.Resource] - Pre-built resource (skips build pipeline) @param {resource.Resource} [.Resource] - Pre-built resource (skips build pipeline)
+4
View File
@@ -43,6 +43,9 @@
"version": "pnpm -F versioning start prod", "version": "pnpm -F versioning start prod",
"changelog": "auto-changelog-plus --starting-date 2021-12-18", "changelog": "auto-changelog-plus --starting-date 2021-12-18",
"gen:lexers": "pnpm -F chroma-lexers start", "gen:lexers": "pnpm -F chroma-lexers start",
"gen:docs": "pnpm -F gen-docs start",
"gen:typedoc": "typedoc",
"gen:sassdoc": "npx sassdoc@2.7.2 assets/scss/ --dest ../fixit-docs/static/references/scss --config .sassdocrc",
"unocss": "unocss", "unocss": "unocss",
"unocss:watch": "unocss --watch", "unocss:watch": "unocss --watch",
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
@@ -61,6 +64,7 @@
"serve": "^14.2.6", "serve": "^14.2.6",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"tsx": "^4.22.4", "tsx": "^4.22.4",
"typedoc": "^0.28.20",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"unocss": "^66.7.4" "unocss": "^66.7.4"
}, },
+1 -1
View File
@@ -30,7 +30,7 @@ export function generateScss(lexers: LexerEntry[]): void {
const lines: string[] = [ const lines: string[] = [
'/// Map of code identifiers/languages to Chroma supported languages.', '/// Map of code identifiers/languages to Chroma supported languages.',
'/// This file is auto-generated by `pnpm gen:lexers`, do not edit manually.', '/// This file is auto-generated by `pnpm gen:lexers`, do not edit manually.',
'/// @see https://github.com/hugo-fixit/FixIt/tree/main/packages/chroma-lexers', '/// See: https://github.com/hugo-fixit/FixIt/tree/main/packages/chroma-lexers',
'$chroma-lexers: (', '$chroma-lexers: (',
] ]
+1 -1
View File
@@ -20,7 +20,7 @@ Then in your `package.json`:
{ {
"scripts": { "scripts": {
"build": "hugo --gc --minify --logLevel info", "build": "hugo --gc --minify --logLevel info",
"postbuild": "fixit-encrypt && fixit-encrypt --verify" "postbuild": "fixit-encrypt"
} }
} }
``` ```
+66
View File
@@ -0,0 +1,66 @@
# @hugo-fixit/gen-docs
Generate API reference documentation for the FixIt Hugo theme from source code.
- **Config**: parses `hugo.toml` to produce Markdown configuration reference.
- **Partials**: parses `layouts/_partials/` to produce Markdown Hugo partials reference.
## Usage
```bash
# Generate all docs (config + partials)
pnpm gen:docs
# Config subcommand
pnpm gen:docs config
pnpm gen:docs config hugo.toml -o /path/to/config.md
pnpm gen:docs config latest
pnpm gen:docs config https://example.com/hugo.toml
pnpm gen:docs config hugo.toml -t template.md
# Partials subcommand
pnpm gen:docs partials
pnpm gen:docs partials -t template.md
pnpm gen:docs partials -o output.md
```
## Subcommands
### `config`
Generates configuration reference from `hugo.toml`. The input source can be:
- **Local file** (default): `pnpm gen:docs config [path]`
- **URL**: `pnpm gen:docs config https://example.com/hugo.toml`
- **Latest release**: `pnpm gen:docs config latest` (fetches from GitHub releases)
Options:
- `-o, --output <file>` — Write output to file (default: stdout)
- `-t, --template <file>` — Inject docs between `<!-- HUGO_FIXIT_PARAMS:START -->` and `<!-- HUGO_FIXIT_PARAMS:END -->` markers in the template file
### `partials`
Generates Hugo partials reference from `layouts/_partials/`.
Options:
- `-o, --output <file>` — Write output to file (default: stdout)
- `-t, --template <file>` — Inject docs between `<!-- HUGO_FIXIT_PARTIALS:START -->` and `<!-- HUGO_FIXIT_PARTIALS:END -->` markers in the template file
## Default Output
When run without a subcommand, generates both config and partials docs (injects into `en` docs templates).
## Architecture
```text
src/
index.ts # CLI entry point (commander)
parsers/
config-parser.ts # TOML parser for hugo.toml
partial-parser.ts # HTML comment parser for layouts/_partials/
renderers/
config.ts # -> config.md
partials.ts # -> partials.md
```
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@hugo-fixit/gen-docs",
"private": true,
"description": "Generate API reference documentation from FixIt source files",
"author": "Lruihao",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts"
},
"devDependencies": {
"@hugo-fixit/shared": "workspace:*",
"commander": "^14.0.3",
"picocolors": "^1.1.1",
"toml": "^3.0.0",
"typescript": "^6.0.3"
}
}
+199
View File
@@ -0,0 +1,199 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { consola, fromRoot } from '@hugo-fixit/shared'
import { Command } from 'commander'
import c from 'picocolors'
import { applyTemplate, generateDocs } from './parsers/config-parser'
import { parsePartials } from './parsers/partial-parser'
import { renderConfigStandalone } from './renderers/config'
import { generatePartialsDocs } from './renderers/partials'
function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8')
consola.success(`Written ${c.cyan(filePath)}`)
}
// ─── Partials helpers ───
const PARTIALS_START = '<!-- HUGO_FIXIT_PARTIALS:START -->'
const PARTIALS_END = '<!-- HUGO_FIXIT_PARTIALS:END -->'
function applyPartialsTemplate(body: string, templatePath: string, outputPath?: string, command?: string): void {
const template = fs.readFileSync(templatePath, 'utf-8')
const startIdx = template.indexOf(PARTIALS_START)
const endIdx = template.indexOf(PARTIALS_END)
if (startIdx === -1 || endIdx === -1) {
throw new Error(`Markers not found in template file. Expected both "${PARTIALS_START}" and "${PARTIALS_END}".`)
}
if (startIdx > endIdx) {
throw new Error('START marker appears after END marker in template file.')
}
const before = template.slice(0, startIdx + PARTIALS_START.length)
const after = template.slice(endIdx)
const comment = command
? `<!--\nAutomatically generated by the \`${command}\` command.\nDo not modify it manually!\n-->\n\n`
: ''
const result = `${before}\n${comment}${body}\n${after}`
const dest = outputPath || templatePath
fs.writeFileSync(dest, result, 'utf-8')
}
function generatePartialsAction(options: { output?: string, template?: string }): void {
const groups = parsePartials()
const body = generatePartialsDocs(groups)
if (options.template) {
const templatePath = path.resolve(process.cwd(), options.template)
if (!fs.existsSync(templatePath)) {
consola.error(`Template not found: ${templatePath}`)
process.exit(1)
}
const outputPath = options.output ? path.resolve(process.cwd(), options.output) : undefined
let cmd = `pnpm gen:docs partials -t ${options.template}`
if (options.output)
cmd += ` -o ${options.output}`
applyPartialsTemplate(body, templatePath, outputPath, cmd)
const dest = outputPath || templatePath
consola.success(`Partials docs applied: ${c.cyan(dest)}`)
return
}
if (options.output) {
const outPath = path.resolve(process.cwd(), options.output)
fs.writeFileSync(outPath, body, 'utf-8')
consola.success(`Written ${c.cyan(outPath)}`)
return
}
// Default: inject into en docs template
const templatePath = fromRoot('../fixit-docs/content/en/references/partials/index.md')
applyPartialsTemplate(body, templatePath, undefined, 'pnpm gen:docs')
}
// ─── Subcommands ───
async function configAction(
file: string,
options: { output?: string, template?: string },
): Promise<void> {
let source = file
let content: string
if (source === 'latest') {
consola.start('Resolving latest FixIt release version...')
try {
const res = await fetch('https://api.github.com/repos/hugo-fixit/FixIt/releases/latest', {
headers: {
'User-Agent': 'gen-docs',
...(process.env.GITHUB_TOKEN && { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }),
},
})
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
const { tag_name: version } = await res.json() as { tag_name: string }
source = `https://raw.githubusercontent.com/hugo-fixit/FixIt/refs/tags/${version}/hugo.toml`
consola.success(`Resolved latest release: ${c.cyan(version)}`)
}
catch (error) {
consola.error(`Failed to resolve latest release: ${(error as Error).message}`)
process.exit(1)
}
}
if (/^https?:\/\//i.test(source)) {
consola.start(`Downloading from: ${c.cyan(source)}`)
try {
const res = await fetch(source)
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
content = await res.text()
consola.success('Remote file downloaded.')
}
catch (error) {
consola.error(`Failed to download: ${(error as Error).message}`)
process.exit(1)
}
}
else {
const inputPath = source === 'hugo.toml' ? fromRoot('hugo.toml') : path.resolve(process.cwd(), source)
if (!fs.existsSync(inputPath)) {
consola.error(`File not found: ${inputPath}`)
process.exit(1)
}
content = fs.readFileSync(inputPath, 'utf-8')
}
if (options.template) {
const templatePath = path.resolve(process.cwd(), options.template)
if (!fs.existsSync(templatePath)) {
consola.error(`Template file not found: ${templatePath}`)
process.exit(1)
}
const outputPath = options.output ? path.resolve(process.cwd(), options.output) : undefined
let cmd = `gen-docs config ${file}`
if (options.template)
cmd += ` -t ${options.template}`
if (options.output)
cmd += ` -o ${options.output}`
applyTemplate(content, templatePath, outputPath, cmd)
const dest = outputPath || templatePath
consola.success(`Documentation applied to template: ${c.cyan(dest)}`)
return
}
const body = generateDocs(content)
if (options.output) {
const outPath = path.resolve(process.cwd(), options.output)
writeFile(outPath, renderConfigStandalone(body))
}
else {
process.stdout.write(`${body}\n`)
}
}
function allAction(): void {
// Config API — inject into docs params page
consola.start('Parsing hugo.toml...')
const tomlContent = fs.readFileSync(fromRoot('hugo.toml'), 'utf-8')
const templatePath = fromRoot('../fixit-docs/content/en/documentation/getting-started/configuration/params/index.md')
applyTemplate(tomlContent, templatePath, undefined, 'pnpm gen:docs')
consola.success('Config docs generated!')
// Partials API — inject into en docs partials page
consola.start('Generating Hugo partials docs...')
generatePartialsAction({})
consola.success('Partials docs generated!')
}
// ─── CLI ───
const program = new Command()
program
.name('gen-docs')
.description('Generate API reference documentation for the FixIt Hugo theme')
program
.command('config')
.description('Generate configuration reference from hugo.toml')
.argument('[file]', 'Input hugo.toml path, URL, or "latest"', 'hugo.toml')
.option('-o, --output <file>', 'Output file path (default: stdout)')
.option('-t, --template <file>', 'Template file with HUGO_FIXIT_PARAMS markers to inject docs into')
.action(configAction)
program
.command('partials')
.description('Generate Hugo partials reference from layouts/_partials/')
.option('-o, --output <file>', 'Output file path (default: stdout)')
.option('-t, --template <file>', 'Template file with HUGO_FIXIT_PARTIALS markers to inject docs into')
.action(generatePartialsAction)
// Default action (no subcommand): inject config + partials docs into docs pages
program
.action(() => allAction())
program.parse()
@@ -0,0 +1,568 @@
import fs from 'node:fs'
import { fromRoot } from '@hugo-fixit/shared'
import toml from 'toml'
interface ParamDoc {
name: string
type: string
defaultValue: string
description: string
children: ParamDoc[]
isTable: boolean
isArray: boolean
rawValue: unknown
}
function isSeparatorComment(text: string): boolean {
return /^[-=+]{3,}$/.test(text.trim())
|| /^[-=]{20,}\s*$/.test(text.trim())
|| /^\+{20,}\s*$/.test(text.trim())
|| /\s+#$/.test(text.trim())
}
function isHardSeparator(text: string): boolean {
return /^[-=]{20,}\s*$/.test(text.trim())
|| /^\+{20,}\s*$/.test(text.trim())
}
function extractComments(content: string): { commentMap: Map<string, string[]>, sectionCommentMap: Map<string, string[]>, pageLevelStartKey: string, pageLevelEndKey: string } {
const commentMap = new Map<string, string[]>()
const sectionCommentMap = new Map<string, string[]>()
const lines = content.split('\n')
const pendingComments: string[] = []
const sectionStack: string[] = []
let inParams = false
let sectionCommentCount = 0
let afterHardSeparator = false
let pageLevelStartKey = ''
let pageLevelEndKey = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/)
if (sectionMatch) {
const key = sectionMatch[1]
if (key === 'params') {
inParams = true
sectionStack.length = 0
sectionStack.push('params')
pendingComments.length = 0
sectionCommentCount = 0
continue
}
if (inParams && key.startsWith('params.')) {
if (afterHardSeparator && !pageLevelEndKey && pageLevelStartKey) {
pageLevelEndKey = key.split('.')[1]
afterHardSeparator = false
}
// Collect section-level comments for empty sections.
// Walk backward from the section header and collect contiguous comment lines.
// Skip blank lines between the section header and comments.
const sectionPath = key.split('.').slice(1).join('.')
let scanIdx = i - 1
const sectionComments: string[] = []
// Skip blank lines immediately before the section header
let hasBlankBeforeComments = false
while (scanIdx >= 0 && lines[scanIdx].trim() === '') {
hasBlankBeforeComments = true
scanIdx--
}
// Collect comment lines
while (scanIdx >= 0 && lines[scanIdx].trim().startsWith('#')) {
sectionComments.unshift(lines[scanIdx].trim().replace(/^#\s?/, ''))
scanIdx--
}
if (sectionComments.length > 0) {
const prevIsSection = scanIdx >= 0 && /^\[[^\]]+\]$/.test(lines[scanIdx].trim())
// Key rule: if there's NO blank line between the section header and the
// comments above it, the comments are section-level for this section.
// If there IS a blank line, the comments are trailing content from the
// previous section (e.g., examples) and should not be assigned here.
if (!hasBlankBeforeComments && prevIsSection) {
sectionCommentMap.set(sectionPath, sectionComments)
}
else if (!prevIsSection) {
// Comments preceded by non-section content or start-of-file
sectionCommentMap.set(sectionPath, sectionComments)
}
}
let splitIdx = pendingComments.length
let foundSplitPoint = false
for (let j = i - 1; j >= 0; j--) {
const prevLine = lines[j].trim()
if (prevLine === '') {
foundSplitPoint = true
break
}
if (prevLine.startsWith('#')) {
splitIdx--
continue
}
foundSplitPoint = true
break
}
if (foundSplitPoint && splitIdx > 0 && sectionStack.length > 1) {
const sectionKey = sectionStack.slice(1).join('.')
commentMap.set(sectionKey, pendingComments.slice(0, splitIdx))
}
const trailing = pendingComments.slice(splitIdx)
pendingComments.length = 0
pendingComments.push(...trailing)
sectionCommentCount = trailing.length
const parts = key.split('.')
sectionStack.length = 0
sectionStack.push(...parts)
continue
}
if (inParams) {
const rootKey = key.split('.')[0]
const nonParamsSections = ['menus', 'markup', 'outputs', 'taxonomies', 'related', 'frontmatter', 'imaging', 'module', 'sitemap', 'minify', 'caches', 'security']
if (nonParamsSections.includes(rootKey)) {
inParams = false
sectionStack.length = 0
pendingComments.length = 0
continue
}
let splitIdx = pendingComments.length
for (let j = i - 1; j >= 0; j--) {
const prevLine = lines[j].trim()
if (prevLine === '')
break
if (prevLine.startsWith('#')) {
splitIdx--
continue
}
break
}
if (splitIdx > 0 && sectionStack.length > 1) {
const sectionKey = sectionStack.slice(1).join('.')
commentMap.set(sectionKey, pendingComments.slice(0, splitIdx))
}
const trailing = pendingComments.slice(splitIdx)
pendingComments.length = 0
pendingComments.push(...trailing)
sectionStack.length = Math.min(sectionStack.length, 1)
sectionStack.push(key)
continue
}
continue
}
if (!inParams)
continue
if (trimmed.startsWith('#')) {
const commentText = trimmed.replace(/^#\s?/, '')
if (isSeparatorComment(commentText)) {
if (isHardSeparator(commentText)) {
pendingComments.length = 0
afterHardSeparator = true
}
continue
}
pendingComments.push(commentText)
continue
}
if (trimmed === '') {
continue
}
const kvMatch = trimmed.match(/^(\w+)\s*=/)
if (kvMatch && sectionStack.length > 0) {
const keyName = kvMatch[1]
const fullPath = [...sectionStack.slice(1), keyName].join('.')
if (afterHardSeparator && !pageLevelStartKey && sectionStack.length === 1) {
pageLevelStartKey = keyName
afterHardSeparator = false
}
if (sectionStack.length > 1 && pendingComments.length > 0) {
const sectionKey = sectionStack.slice(1).join('.')
if (!commentMap.has(sectionKey) && sectionCommentCount > 0) {
commentMap.set(sectionKey, pendingComments.slice(0, sectionCommentCount))
pendingComments.splice(0, sectionCommentCount)
}
sectionCommentCount = 0
}
if (pendingComments.length > 0) {
commentMap.set(fullPath, [...pendingComments])
pendingComments.length = 0
}
}
const arrayMatch = trimmed.match(/^\[\[(\w+(?:\.\w+)*)\]\]$/)
if (arrayMatch && inParams) {
const keyPath = arrayMatch[1]
const fullPath = [...sectionStack.slice(1), keyPath].join('.')
if (pendingComments.length > 0) {
commentMap.set(fullPath, [...pendingComments])
pendingComments.length = 0
}
}
}
return { commentMap, sectionCommentMap, pageLevelStartKey, pageLevelEndKey }
}
function detectType(value: unknown): string {
if (value === null || value === undefined)
return 'string'
if (typeof value === 'boolean')
return 'bool'
if (typeof value === 'number')
return Number.isInteger(value) ? 'int' : 'float'
if (typeof value === 'string')
return 'string'
if (Array.isArray(value)) {
if (value.length === 0)
return 'string array'
return `${detectType(value[0])} array`
}
if (typeof value === 'object')
return 'map'
return 'string'
}
function formatDefault(value: unknown): string {
if (value === null || value === undefined)
return ''
if (typeof value === 'string')
return value === '' ? '`""`' : `\`${JSON.stringify(value)}\``
if (typeof value === 'boolean')
return `\`${value}\``
if (typeof value === 'number')
return `\`${value}\``
if (Array.isArray(value)) {
if (value.length === 0)
return '`[]`'
return `\`${JSON.stringify(value)}\``
}
return ''
}
function formatTomlValue(value: unknown): string {
if (value === null || value === undefined)
return '""'
if (typeof value === 'string')
return JSON.stringify(value)
if (typeof value === 'boolean')
return String(value)
if (typeof value === 'number')
return String(value)
if (Array.isArray(value)) {
if (value.length === 0)
return '[]'
return JSON.stringify(value)
}
return '""'
}
function isExampleLine(line: string): boolean {
return /^\s*(?:"[^"]*"|'[^']*'|[\w.-]+)\s*=\s*.+/.test(line)
}
function joinComments(comments: string[]): string {
if (comments.length === 0)
return ''
const filtered = comments.filter(c => c.trim() !== '')
if (filtered.length === 0)
return ''
const sentences = filtered.map((line) => {
const trimmed = line.trim()
if (isExampleLine(trimmed)) {
return `Example: \`${trimmed}\`.`
}
let s = trimmed
if (!s.startsWith('http://') && !s.startsWith('https://')) {
s = s.charAt(0).toUpperCase() + s.slice(1)
}
if (!s.endsWith('.') && !s.endsWith('!') && !s.endsWith('?'))
s += '.'
return s
})
return sentences.join(' ')
}
function documentSection(
obj: Record<string, unknown>,
commentMap: Map<string, string[]>,
parentPath: string,
sectionCommentMap?: Map<string, string[]>,
): ParamDoc[] {
const docs: ParamDoc[] = []
const seenKeys = new Set<string>()
for (const [key, value] of Object.entries(obj)) {
const fullPath = parentPath ? `${parentPath}.${key}` : key
const comments = commentMap.get(fullPath) || []
// Fall back to section-level comments if no key-level comments
const sectionComments = sectionCommentMap?.get(fullPath)
const effectiveComments = comments.length > 0 ? comments : (sectionComments || [])
const type = detectType(value)
const isTable = typeof value === 'object' && !Array.isArray(value) && value !== null
const isArray = Array.isArray(value)
const doc: ParamDoc = {
name: key,
type,
defaultValue: formatDefault(value),
description: joinComments(effectiveComments),
children: [],
isTable,
isArray,
rawValue: value,
}
if (isTable && !isArray) {
doc.children = documentSection(
value as Record<string, unknown>,
commentMap,
fullPath,
sectionCommentMap,
)
}
docs.push(doc)
seenKeys.add(key)
}
// Add placeholder entries for empty sections that have section-level comments
if (sectionCommentMap) {
for (const [sectionPath, comments] of sectionCommentMap) {
const prefix = parentPath ? `${parentPath}.` : ''
if (sectionPath.startsWith(prefix) && !sectionPath.slice(prefix.length).includes('.')) {
const key = sectionPath.slice(prefix.length)
if (key && !seenKeys.has(key)) {
docs.push({
name: key,
type: 'map',
defaultValue: '',
description: joinComments(comments),
children: [],
isTable: true,
isArray: false,
rawValue: {},
})
seenKeys.add(key)
}
}
}
}
return docs
}
function rawDefault(doc: ParamDoc): string {
const val = doc.defaultValue.replace(/`/g, '')
return val || '""'
}
function renderTomlBlock(doc: ParamDoc, lines: string[], sectionPath: string): void {
lines.push(`[params.${sectionPath}]`)
for (const child of doc.children) {
if (child.isTable) {
lines.push('')
lines.push(`[params.${sectionPath}.${child.name}]`)
for (const grandchild of child.children) {
if (grandchild.isTable) {
lines.push('')
lines.push(`[params.${sectionPath}.${child.name}.${grandchild.name}]`)
for (const gg of grandchild.children) {
lines.push(`${gg.name} = ${rawDefault(gg)}`)
}
}
else {
lines.push(`${grandchild.name} = ${rawDefault(grandchild)}`)
}
}
}
else if (child.isArray && Array.isArray(child.rawValue) && child.rawValue.length > 0 && typeof child.rawValue[0] === 'object') {
for (const item of child.rawValue) {
lines.push('')
lines.push(`[[params.${sectionPath}.${child.name}]]`)
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
lines.push(`${k} = ${formatTomlValue(v)}`)
}
}
}
else {
lines.push(`${child.name} = ${rawDefault(child)}`)
}
}
}
function renderChildren(children: ParamDoc[], lines: string[]): void {
for (const child of children) {
let meta = `\`${child.type}\``
const desc = child.description.replace(/\.$/, '')
if (child.defaultValue && child.type !== 'map') {
meta += desc ? ` ${desc}. Default is ${child.defaultValue}.` : ` Default is ${child.defaultValue}.`
}
else if (desc) {
meta += ` ${desc}.`
}
lines.push(child.name)
lines.push(`: ${meta}`)
lines.push('')
if (child.children.length > 0) {
for (const grandchild of child.children) {
let gcMeta = `\`${grandchild.type}\``
const gcDesc = grandchild.description.replace(/\.$/, '')
if (grandchild.defaultValue && grandchild.type !== 'map') {
gcMeta += gcDesc ? ` ${gcDesc}. Default is ${grandchild.defaultValue}.` : ` Default is ${grandchild.defaultValue}.`
}
else if (gcDesc) {
gcMeta += ` ${gcDesc}.`
}
lines.push(`- ${grandchild.name}: ${gcMeta}`)
}
lines.push('')
}
}
}
function renderParam(doc: ParamDoc): string {
const lines: string[] = []
let meta = `\`${doc.type}\``
const desc = doc.description.replace(/\.$/, '')
if (doc.defaultValue && doc.type !== 'map') {
meta += desc ? ` ${desc}. Default is ${doc.defaultValue}.` : ` Default is ${doc.defaultValue}.`
}
else if (desc) {
meta += ` ${desc}.`
}
lines.push(`### ${doc.name}`)
lines.push('')
lines.push(meta)
if (doc.children.length > 0) {
lines.push('')
const hasContent = (children: ParamDoc[]): boolean =>
children.some(child => !child.isTable || (child.children.length > 0 && hasContent(child.children)))
const codeBlock = hasContent(doc.children) ? '```toggle' : '```toml'
lines.push(codeBlock)
lines.push('[params]')
lines.push('')
renderTomlBlock(doc, lines, doc.name)
lines.push('```')
lines.push('')
renderChildren(doc.children, lines)
}
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop()
}
return lines.map(line => line.trimEnd()).join('\n')
}
/**
* Generate theme configuration documentation from TOML content string.
* @param content TOML content string
* @returns Markdown documentation body (no frontmatter)
*/
export function generateDocs(content: string): string {
const parsed = toml.parse(content)
if (!parsed.params) {
throw new Error('No [params] section found in hugo.toml.')
}
const { commentMap, sectionCommentMap, pageLevelStartKey, pageLevelEndKey } = extractComments(content)
const params = parsed.params as Record<string, unknown>
const docs = documentSection(params, commentMap, '', sectionCommentMap)
const siteLevel: ParamDoc[] = []
const pageLevel: ParamDoc[] = []
let isPageLevel = false
for (const doc of docs) {
if (pageLevelStartKey && doc.name === pageLevelStartKey) {
isPageLevel = true
}
if (pageLevelEndKey && doc.name === pageLevelEndKey) {
isPageLevel = false
}
if (isPageLevel) {
pageLevel.push(doc)
}
else {
siteLevel.push(doc)
}
}
const output: string[] = []
if (siteLevel.length > 0) {
output.push('## Site Level')
output.push('These apply to the entire site and cannot be overridden on a per-page basis.')
for (const doc of siteLevel) {
output.push(renderParam(doc))
}
}
if (pageLevel.length > 0) {
output.push('## Page Level')
output.push('These can be overridden on a per-page basis via front matter.')
for (const doc of pageLevel) {
output.push(renderParam(doc))
}
}
return output.join('\n\n').split('\n').map(line => line.trimEnd()).join('\n')
}
/**
* Convenience: read hugo.toml from the FixIt repo root and generate docs.
*/
export function parseConfig(): string {
const content = fs.readFileSync(fromRoot('hugo.toml'), 'utf-8')
return generateDocs(content)
}
/**
* Apply generated docs into a template file between markers.
* Replaces content between `<!-- HUGO_FIXIT_PARAMS:START -->` and `<!-- HUGO_FIXIT_PARAMS:END -->`.
* @param tomlContent TOML content string
* @param templatePath Path to the template file
* @param outputPath Optional output path (defaults to overwriting the template file)
* @param command The CLI command that was used to generate the docs
*/
export function applyTemplate(tomlContent: string, templatePath: string, outputPath?: string, command?: string): void {
const template = fs.readFileSync(templatePath, 'utf-8')
const docs = generateDocs(tomlContent)
const startMarker = '<!-- HUGO_FIXIT_PARAMS:START -->'
const endMarker = '<!-- HUGO_FIXIT_PARAMS:END -->'
const startIdx = template.indexOf(startMarker)
const endIdx = template.indexOf(endMarker)
if (startIdx === -1 || endIdx === -1) {
throw new Error(`Markers not found in template file. Expected both "${startMarker}" and "${endMarker}".`)
}
if (startIdx > endIdx) {
throw new Error('START marker appears after END marker in template file.')
}
const before = template.slice(0, startIdx + startMarker.length)
const after = template.slice(endIdx)
const comment = command
? `<!--\nAutomatically generated by the \`${command}\` command.\nDo not modify it manually!\n-->\n\n`
: ''
const result = `${before}\n${comment}${docs}\n${after}`
const dest = outputPath || templatePath
fs.writeFileSync(dest, result, 'utf-8')
}
@@ -0,0 +1,337 @@
import fs from 'node:fs'
import path from 'node:path'
import { fromRoot } from '@hugo-fixit/shared'
export interface PartialParam {
name: string
type: string
description: string
children: PartialParam[]
}
export interface PartialDoc {
name: string
path: string
description: string
params: PartialParam[]
returns: string
examples: string[]
}
export interface PartialGroup {
name: string
description: string
partials: PartialDoc[]
}
const GROUP_DESCRIPTIONS: Record<string, string> = {
_debug: 'Debug utilities for development.',
base: 'Core layout partials (header, footer, breadcrumb, paginator, comment, widgets, assets).',
feed: 'RSS feed generation.',
function: 'Reusable utility and helper function partials.',
gen: 'Generated or config output partials.',
home: 'Homepage-specific partials.',
init: 'Theme initialization partials (version, environment detection, compatibility, global setup).',
plugin: 'Third-party plugin integration partials.',
section: 'Section-level partials.',
single: 'Single-post page partials.',
store: 'Asset accumulation store partials.',
}
/**
* Extract the leading Hugo comment block from file content.
*/
function extractComment(content: string): string | null {
const match = content.match(/^\{\{-?\s*\/\*[\s\S]*?\*\/\s*-?\}\}/)
if (!match)
return null
// Strip the delimiters: {{- /* at start, */ -}} at end
const inner = match[0]
.replace(/^\{\{-?\s*\/\*\s*/, '')
.replace(/\s*\*\/\s*-?\}\}$/, '')
return inner.trim()
}
/**
* Split a comment block into sections by @tags.
* Returns an array of {key, lines} pairs, preserving order and duplicates.
*/
function splitByTags(block: string): { key: string, lines: string[] }[] {
const sections: { key: string, lines: string[] }[] = []
const lines = block.split('\n')
let currentKey = '_desc'
let current: string[] = []
function flush() {
if (current.length > 0) {
sections.push({ key: currentKey, lines: [...current] })
current = []
}
}
for (const line of lines) {
const trimmed = line.trim()
const tagMatch = trimmed.match(/^@(param|return|example)\b/)
if (tagMatch) {
flush()
currentKey = tagMatch[1]
current.push(line)
}
else {
current.push(line)
}
}
flush()
return sections
}
/**
* Parse `@param {Type} .Name - description` lines from param sections.
*/
function parseParams(paramLines: string[]): PartialParam[] {
const params: PartialParam[] = []
let current: PartialParam | null = null
for (const line of paramLines) {
const trimmed = line.trim()
if (!trimmed)
continue
const paramMatch = trimmed.match(/^@param \{([^}]+)\} (\S+)(?:\s*- (.+))?$/)
if (paramMatch) {
current = {
name: paramMatch[2],
type: paramMatch[1],
description: paramMatch[3] || '',
children: [],
}
params.push(current)
continue
}
const childMatch = trimmed.match(/^-\s+(\w+): (.*)$/)
if (childMatch && current) {
current.children.push({
name: childMatch[1],
type: '',
description: childMatch[2].trim(),
children: [],
})
continue
}
if (current && !trimmed.startsWith('@')) {
current.description += (current.description ? ' ' : '') + trimmed
}
}
return params
}
/**
* Parse `@return {Type} description` from return sections.
*/
function parseReturn(returnLines: string[]): string {
for (const line of returnLines) {
const match = line.trim().match(/@return\s+\{([^}]+)\}\s*(.*)/)
if (match)
return `\`${match[1]}\`${match[2] ? ` - ${match[2].trim()}` : ''}`
}
return ''
}
/**
* Parse `@example` blocks from example sections.
*/
/**
* Strip common leading whitespace from an array of lines.
*/
function dedent(lines: string[]): string[] {
const nonEmpty = lines.filter(l => l.trim() !== '')
if (nonEmpty.length === 0)
return lines
const minIndent = Math.min(...nonEmpty.map(l => l.match(/^(\s*)/)![1].length))
return lines.map(l => l.slice(minIndent))
}
function parseExamples(exampleLines: string[]): string[] {
const examples: string[] = []
const current: string[] = []
let firstLine = true
for (const line of exampleLines) {
const trimmed = line.trim()
if (firstLine) {
firstLine = false
const inline = trimmed.replace(/^@example\s*/, '')
if (inline) {
examples.push(inline)
continue
}
continue
}
current.push(line)
}
if (current.length > 0) {
examples.push(dedent(current).join('\n').trim())
}
return examples.filter(Boolean)
}
/**
* Extract description text from the pre-tag section.
* Preserves bullet lists (lines starting with `- `) and paragraph breaks.
*/
function parseDescription(descLines: string[]): string {
const lines = descLines
.map(l => l.trim())
.filter(l => l !== '---' && !l.startsWith('- .'))
const result: string[] = []
let paragraph: string[] = []
function flushParagraph() {
if (paragraph.length > 0) {
let text = paragraph.join(' ')
if (text && !/^https?:\/\//i.test(text))
text = text.charAt(0).toUpperCase() + text.slice(1)
if (text && !/[.!?:]$/.test(text))
text += '.'
result.push(text)
paragraph = []
}
}
for (const line of lines) {
if (line === '') {
flushParagraph()
}
else if (line.startsWith('- ')) {
flushParagraph()
const text = line.slice(2)
const capitalized = /^https?:\/\//i.test(text) ? text : `${text.charAt(0).toUpperCase()}${text.slice(1)}`
result.push(`- ${capitalized}`)
}
else {
paragraph.push(line)
}
}
flushParagraph()
// Join with double newlines, but consecutive list items use single newlines
let output = ''
for (let i = 0; i < result.length; i++) {
if (i > 0) {
const prevIsList = result[i - 1].startsWith('- ')
const currIsList = result[i].startsWith('- ')
output += (prevIsList && currIsList) ? '\n' : '\n\n'
}
output += result[i]
}
return output.trim()
}
/**
* Parse a single partial HTML file into a PartialDoc.
*/
function parsePartialFile(filePath: string, relativePath: string): PartialDoc {
const content = fs.readFileSync(filePath, 'utf-8')
const comment = extractComment(content)
const doc: PartialDoc = {
name: relativePath,
path: relativePath,
description: '',
params: [],
returns: '',
examples: [],
}
if (!comment)
return doc
const sections = splitByTags(comment)
const descLines = sections.filter(s => s.key === '_desc').flatMap(s => s.lines)
const paramLines = sections.filter(s => s.key === 'param').flatMap(s => s.lines)
const returnLines = sections.filter(s => s.key === 'return').flatMap(s => s.lines)
const exampleSections = sections.filter(s => s.key === 'example')
doc.description = parseDescription(descLines)
doc.params = parseParams(paramLines)
doc.returns = parseReturn(returnLines)
doc.examples = exampleSections.flatMap(s => parseExamples(s.lines))
return doc
}
/**
* Recursively collect all .html files from a directory.
*/
function collectHtmlFiles(dir: string, baseDir: string): { filePath: string, relativePath: string }[] {
const results: { filePath: string, relativePath: string }[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (entry.isDirectory()) {
results.push(...collectHtmlFiles(fullPath, baseDir))
}
else if (entry.name.endsWith('.html')) {
results.push({ filePath: fullPath, relativePath })
}
}
return results
}
/**
* Parse all Hugo partials from layouts/_partials/ and group by directory.
*/
export function parsePartials(partialsDir?: string): PartialGroup[] {
const dir = partialsDir || fromRoot('layouts/_partials')
if (!fs.existsSync(dir)) {
throw new Error(`Partials directory not found: ${dir}`)
}
const files = collectHtmlFiles(dir, dir)
const groupMap = new Map<string, PartialDoc[]>()
for (const { filePath, relativePath } of files) {
const doc = parsePartialFile(filePath, relativePath)
const dirName = path.dirname(relativePath)
const group = dirName === '.' ? '(root)' : dirName
if (!groupMap.has(group)) {
groupMap.set(group, [])
}
groupMap.get(group)!.push(doc)
}
// Sort groups by name, with (root) last
const groups: PartialGroup[] = []
const sortedKeys = [...groupMap.keys()].sort((a, b) => {
if (a === '(root)')
return 1
if (b === '(root)')
return -1
return a.localeCompare(b)
})
for (const key of sortedKeys) {
const partials = groupMap.get(key)!.sort((a, b) => a.name.localeCompare(b.name))
groups.push({
name: key,
description: GROUP_DESCRIPTIONS[key] || '',
partials,
})
}
return groups
}
+42
View File
@@ -0,0 +1,42 @@
function now(): string {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00+08:00`
}
export function renderConfig(body: string): string {
const lines: string[] = []
lines.push('---')
lines.push('title: Configuration Reference')
lines.push('shortTitle: Configuration')
lines.push(`date: ${now()}`)
lines.push('description: Hugo configuration reference for the FixIt Hugo theme.')
lines.push('collections:')
lines.push(' - References')
lines.push('---')
lines.push('')
lines.push('This page is auto-generated from `hugo.toml`. Do not edit manually.')
lines.push('')
lines.push('<!--more-->')
lines.push('')
lines.push(body)
return lines.join('\n')
}
/**
* Render standalone config docs with frontmatter (for CLI -o output).
*/
export function renderConfigStandalone(body: string): string {
const lines: string[] = []
lines.push('---')
lines.push('title: FixIt Theme Configuration')
lines.push(`date: ${now()}`)
lines.push('---')
lines.push('')
lines.push(body)
return lines.join('\n')
}
+142
View File
@@ -0,0 +1,142 @@
import type { PartialDoc, PartialGroup } from '../parsers/partial-parser'
/**
* Escape HTML tags in text to prevent them from being rendered as actual HTML.
* Skips content inside backtick code spans.
*/
function escapeHtml(text: string): string {
return text.replace(/`[^`]*`|<[^>]+>/g, (match) => {
if (match.startsWith('`'))
return match
return match.replace(/</g, '&lt;').replace(/>/g, '&gt;')
})
}
function renderParamTable(params: PartialDoc['params']): string {
if (params.length === 0)
return ''
const lines: string[] = []
lines.push('**Parameters:**')
lines.push('')
lines.push('| Name | Type | Description |')
lines.push('|------|------|-------------|')
for (const param of params) {
const name = `\`${param.name}\``
const type = param.type ? `\`${param.type}\`` : ''
const desc = escapeHtml(param.description || '')
lines.push(`| ${name} | ${type} | ${desc} |`)
for (const child of param.children) {
const childFullName = param.name === '.' ? `.${child.name}` : `${param.name}.${child.name}`
const childName = `\`${childFullName}\``
const childType = child.type ? `\`${child.type}\`` : ''
const childDesc = escapeHtml(child.description || '')
lines.push(`| ${childName} | ${childType} | ${childDesc} |`)
}
}
return lines.join('\n')
}
function renderPartial(doc: PartialDoc): string {
const lines: string[] = []
lines.push(`### ${doc.name}`)
lines.push('')
if (!doc.description && doc.params.length === 0 && !doc.returns && doc.examples.length === 0) {
lines.push('_No documentation._')
return lines.join('\n')
}
if (doc.description) {
lines.push(escapeHtml(doc.description))
lines.push('')
}
const paramTable = renderParamTable(doc.params)
if (paramTable) {
lines.push(paramTable)
lines.push('')
}
if (doc.returns) {
lines.push(`**Returns:** ${escapeHtml(doc.returns)}`)
lines.push('')
}
if (doc.examples.length > 0) {
lines.push('**Example:**')
lines.push('')
for (const example of doc.examples) {
lines.push('```go-template')
lines.push((example))
lines.push('```')
lines.push('')
}
}
// Trim trailing blank lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop()
}
return lines.join('\n')
}
function renderGroup(group: PartialGroup): string {
const lines: string[] = []
const count = group.partials.length
const header = group.name === '(root)' ? '(root)' : `${group.name}/`
lines.push(`## ${header}`)
lines.push('')
if (group.description) {
lines.push(`> ${group.description}`)
lines.push('')
}
lines.push(`> ${count} partial${count !== 1 ? 's' : ''}`)
lines.push('')
for (const partial of group.partials) {
lines.push(renderPartial(partial))
lines.push('')
}
return lines.join('\n')
}
/**
* Generate Hugo partials documentation from parsed groups.
* @param groups Parsed partial groups
* @returns Markdown documentation body (no frontmatter)
*/
export function generatePartialsDocs(groups: PartialGroup[]): string {
const lines: string[] = []
// Summary
const totalCount = groups.reduce((sum, g) => sum + g.partials.length, 0)
lines.push(`The FixIt theme provides **${totalCount}** Hugo partials across **${groups.length}** groups.`)
lines.push('')
// TOC
lines.push('## Groups')
lines.push('')
for (const group of groups) {
const header = group.name === '(root)' ? '(root)' : `${group.name}/`
const count = group.partials.length
lines.push(`- [${header}](#${group.name.replace(/[()/]/g, '').toLowerCase()}) — ${count} partial${count !== 1 ? 's' : ''}`)
}
lines.push('')
// Groups
for (const group of groups) {
lines.push(renderGroup(group))
}
return lines.join('\n')
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "nodenext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+792 -918
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,5 +7,6 @@ packages:
- packages/* - packages/*
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true
core-js: true
esbuild: true esbuild: true
simple-git-hooks: true simple-git-hooks: true
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": [
"assets/js"
],
"entryPointStrategy": "expand",
"tsconfig": "assets/tsconfig.json",
"out": "../fixit-docs/static/references/javascript",
"skipErrorChecking": true,
"excludePrivate": true,
"excludeInternal": true,
"exclude": [
"assets/js/types/index.ts",
"assets/js/utils/index.ts",
"**/main.ts",
"**/head/**",
"**/lib/**",
"**/pages/**"
],
"name": "FixIt JavaScript API",
"navigationLinks": {
"FixIt Documentation": "https://fixit.lruihao.cn",
"GitHub": "https://github.com/hugo-fixit/FixIt"
},
"categorizeByGroup": true,
"readme": "none",
"sourceLinkTemplate": "https://github.com/hugo-fixit/FixIt/blob/{gitRevision}/{path}#L{line}"
}